diff --git a/README.md b/README.md index 3ca0074..acc550a 100644 --- a/README.md +++ b/README.md @@ -63,58 +63,108 @@ Anyone can also install straight from a repository, without the registry: ## First-party extensions -48 extensions, plus npm and System bundled with the launcher. - -| Extension | What it does | Permissions | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| [AI](extensions/ai) | Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama | `api.anthropic.com`, `api.openai.com`… | -| [Browser](extensions/browser) | History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi | reads ~/.mozilla/firefox, ~/snap/firefox/common/.mozilla/firefox… | -| [Caffeine](extensions/caffeine) | Keep the computer awake for a while, or until you turn it off, even with Backslash closed | runs `systemd-run`, `systemctl`, `systemd-inhibit` | -| [Calendar](extensions/calendar) | Your upcoming events from Google, Outlook, iCloud, Fastmail or Proton calendars, with one-key meeting joins | `calendar.google.com`, `outlook.office365.com`… | -| [Code Projects](extensions/projects) | Recent projects of VS Code, VSCodium, Cursor, Zed and JetBrains IDEs, plus your git folders | runs `git`, `code`, `code-oss`…; reads ~/.config/Code, ~/.config/Code - OSS… | -| [Color Picker](extensions/color-picker) | Pick a color anywhere on the screen, keep a history, and convert colors between HEX, RGB, HSL and OKLCH | runs `systemd-run`, `hyprpicker`, `grim`…; reads ~/.cache/backslash/color-picker, /etc/os-release | -| [Containers & VMs](extensions/distrobox) | Enter distrobox and toolbx containers, and start, stop or open your libvirt virtual machines | runs `distrobox`, `toolbox`, `podman`… | -| [Containers](extensions/docker) | Docker and Podman containers: start, stop, logs | runs `docker`, `podman` | -| [Deployments](extensions/deployments) | Recent Vercel deployments and Netlify deploys with their status, commit, branch and build time; open, copy, redeploy, cancel, build logs | `api.vercel.com`, `api.netlify.com`… | -| [Desktop Settings](extensions/desktop-settings) | Search every settings panel of GNOME, KDE Plasma, Xfce or Cinnamon in English or French, and flip quick toggles | runs `gsettings`, `kreadconfig6`, `kreadconfig5`…; reads /usr/bin, /usr/local/bin… | -| [Dev Utils](extensions/dev-utils) | Encode, decode, hash, convert and generate developer values right from the search bar | none | -| [Dictionary](extensions/dictionary) | Definitions, examples, pronunciation and synonyms in English and French from Wiktionary, and quick translations | `en.wiktionary.org`, `fr.wiktionary.org`… | -| [Display](extensions/display) | Screen brightness with presets, night light, and your monitors' resolution, refresh rate and scale | runs `brightnessctl`, `udevadm`, `busctl`…; reads /etc/os-release | -| [Docs](extensions/docs) | Search developer documentation: DevDocs, tldr pages, the Arch Wiki and your man pages | `devdocs.io`, `documents.devdocs.io`…; runs `man`, `apropos`, `kitty`… | -| [Drives](extensions/drives) | Mount, open, unmount and safely eject USB sticks, SD cards and internal drives | runs `lsblk`, `udisksctl` | -| [Emoji & Symbols](extensions/emoji) | Search emoji and symbols by name and copy them | none | -| [Games](extensions/games) | Launch your installed Steam, Lutris and Heroic (Epic, GOG, Amazon) games, with cover art, play time, size and ProtonDB ratings | `www.protondb.com`, `shared.steamstatic.com`…; runs `lutris`; reads ~/.local/share/Steam, ~/.steam… | -| [GitHub](extensions/github) | Pull requests with their checks and reviews, your issues, and repository search | `api.github.com`, `avatars.githubusercontent.com`; runs `gh` | -| [GitLab](extensions/gitlab) | Merge requests with pipelines and approvals, your issues, projects and pipelines on gitlab.com (self-hosted instances are not supported) | `gitlab.com`, `secure.gravatar.com` | -| [Hacker News & RSS](extensions/hacker-news) | Top, new, best, Ask and Show HN stories with their comments, and your RSS and Atom feeds with unread tracking | any website | -| [Home Assistant](extensions/home-assistant) | Lights, switches, covers, climate, media players, scenes, scripts and sensors of your Home Assistant: toggle, dim, set the temperature, run scenes | `localhost`, `127.0.0.1`… | -| [Jira](extensions/jira) | Your Jira Cloud issues by status with priority, sprint and labels; change status, assign, search with text or JQL, create issues | `*.atlassian.net`, `secure.gravatar.com`… | -| [Kubernetes](extensions/kubernetes) | Browse pods with their status, logs and restarts, and switch kubectl contexts and namespaces | runs `kubectl`, `kitty`, `alacritty`… | -| [Linear](extensions/linear) | Your issues with their status, priority, labels and assignee; search, triage and quick issue creation | `api.linear.app`, `public.linear.app` | -| [Media Controls](extensions/media) | Control Spotify, browsers, VLC, mpv and any MPRIS player | `i.scdn.co`; runs `busctl`; reads /tmp/backslash-media | -| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs and VPNs in two keystrokes | runs `nmcli`, `bluetoothctl`, `pactl`… | -| [Notifications](extensions/notifications) | Do Not Disturb and the notification history for mako, Dunst, SwayNotificationCenter, GNOME, Plasma, Xfce and Cinnamon | runs `gdbus`, `makoctl`, `dunstctl`… | -| [Notion](extensions/notion) | Search the pages and databases shared with your integration, read them, add text to a page, create pages in a database | `api.notion.com` | -| [Obsidian](extensions/obsidian) | Search your Obsidian notes by title, tag and text, preview them, open today's daily note and capture ideas | reads ~ | -| [Packages](extensions/packages) | Search your distribution's packages and Flathub | runs `apt-cache`, `dnf`, `pacman`… | -| [Passwords](extensions/passwords) | Copy passwords, usernames and one-time codes from pass, Bitwarden, 1Password and KeePassXC | runs `pass`, `bw`, `op`…; reads ~/.password-store | -| [Ports](extensions/ports) | What listens on :3000? Dev servers and services by port, with their process and project | runs `ss`, `ps`, `readlink`… | -| [Power](extensions/power) | Switch power profiles and check your battery's charge, health, cycles and time left | runs `powerprofilesctl`, `tuned-adm`, `udevadm`… | -| [Quick Notes](extensions/quick-notes) | Jot, find, pin and paste notes kept as plain markdown files, ready for Obsidian, your editor or sync | reads ~/Documents | -| [Screenshot & Recording](extensions/screenshot) | Capture an area, a window or the whole screen, record the screen, and find your recent captures | runs `systemd-run`, `systemctl`, `grim`…; reads ~/Pictures, ~/Images… | -| [Sentry](extensions/sentry) | Unresolved issues across your projects with events, users and assignee; resolve, ignore, assign; latest event with its stack trace (sentry.io, US and EU regions) | `sentry.io`, `us.sentry.io`… | -| [Services](extensions/systemd) | Start, stop and inspect systemd services | runs `systemctl`, `journalctl` | -| [Spotify](extensions/spotify) | Search Spotify, play on any of your devices, queue songs, browse your playlists and save what's playing to your library | `api.spotify.com`, `accounts.spotify.com`… | -| [SSH Hosts](extensions/ssh) | Connect to the hosts of your SSH config and known_hosts in a terminal or the file manager | runs `kitty`, `alacritty`, `wezterm`…; reads ~/.ssh | -| [System Monitor](extensions/system-monitor) | Live CPU, memory, disk, network, GPU and battery usage, and the processes behind it | runs `grep`, `ps`, `df`…; reads /etc/os-release | -| [System Updates](extensions/updates) | Available updates from APT, DNF, Pacman, Zypper, Flatpak, Snap and firmware, applied in a terminal | runs `apt`, `dnf`, `rpm`…; reads /etc/os-release | -| [Terminal Sessions](extensions/terminal-sessions) | Attach to, create, rename and kill your tmux and zellij sessions | runs `tmux`, `zellij`, `kitty`… | -| [Text from Screen](extensions/ocr) | Select any part of the screen and copy the text in it, recognized offline with Tesseract | runs `systemd-run`, `tesseract`, `grim`…; reads ~/.cache/backslash/ocr, /etc/os-release | -| [Timers](extensions/timers) | Timers and Pomodoro that ring even when Backslash is closed (systemd timers) | runs `systemd-run`, `systemctl` | -| [Todoist](extensions/todoist) | Today's tasks, search and quick add in natural language | `api.todoist.com` | -| [Weather](extensions/weather) | Current conditions, the next 24 hours and the week ahead for any city, from Open-Meteo | `geocoding-api.open-meteo.com`, `api.open-meteo.com` | -| [Web Search](extensions/web-search) | Search the web with live suggestions, 50 !bangs and your recent searches, on the engine you prefer | `duckduckgo.com`, `suggestqueries.google.com` | -| [World Clock](extensions/world-clock) | The time in your cities, offsets, day and night, and time conversions like “3pm NY in Paris” | none | +98 extensions, plus npm and System bundled with the launcher. + +| Extension | What it does | Permissions | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| [AI](extensions/ai) | Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama | `api.anthropic.com`, `api.openai.com`… | +| [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… | Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged. diff --git a/extensions/anki/anki.ts b/extensions/anki/anki.ts new file mode 100644 index 0000000..5149cda --- /dev/null +++ b/extensions/anki/anki.ts @@ -0,0 +1,241 @@ +// Talking to Anki through AnkiConnect, and the shared views. + +import { + type Action, + action, + close, + type Context, + copy, + exec, + field, + form, + type FormSpec, + Icons, + type Item, + open, + pop, + storage, + toast, +} from "@backslash/api"; +import { + ANKICONNECT_CODE, + ANKICONNECT_URL, + BASIC, + buildDecks, + CLOZE, + type Deck, + type DeckStats, + ENDPOINT, + installHint, + noteFields, + noteParams, + orderModels, + parseTags, + request, + REVERSED, + toHtml, + unwrap, + visibleDecks, +} from "./lib.ts"; + +/** Anki is closed, or AnkiConnect isn't installed. */ +export class OfflineError extends Error {} + +export async function anki( + ctx: Pick, + action: string, + params: Record = {}, + signal?: AbortSignal, +): Promise { + const key = String(ctx.preferences.apiKey ?? "").trim() || undefined; + let res: Response; + try { + res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request(action, params, key)), + signal: signal ?? AbortSignal.timeout(action === "sync" ? 120_000 : 10_000), + }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw e; + throw new OfflineError("Anki isn't running, or AnkiConnect isn't installed"); + } + if (!res.ok) throw new Error(`AnkiConnect answered ${res.status}`); + return unwrap(await res.json()); +} + +export async function loadDecks(ctx: Pick, signal?: AbortSignal): Promise { + const names = await anki>(ctx, "deckNamesAndIds", {}, signal); + const stats = await anki>(ctx, "getDeckStats", { decks: Object.keys(names) }, signal); + return visibleDecks(buildDecks(names, stats)); +} + +// --- Opening Anki ------------------------------------------------------------ + +const HOME = Deno.env.get("HOME") ?? ""; + +/** Where Anki is installed, in the order tried; the same paths as `[permissions] read`. */ +const LAUNCHERS = [ + "/usr/bin/anki", + "/usr/local/bin/anki", + `${HOME}/.local/bin/anki`, + "/var/lib/flatpak/exports/bin/net.ankiweb.Anki", + `${HOME}/.local/share/flatpak/exports/bin/net.ankiweb.Anki`, + "/snap/bin/anki", +]; + +export async function findAnki(): Promise { + for (const path of LAUNCHERS) { + if (await Deno.stat(path).then((s) => s.isFile, () => false)) return path; + } + return undefined; +} + +/** Brings Anki to the front, or starts it in the background (it outlives this command). */ +export async function openAnki(ctx: Pick): Promise { + try { + await anki(ctx, "guiDeckBrowser"); + return "Anki opened"; + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + } + const path = await findAnki(); + if (!path) throw new Error("Anki isn't installed: get it from apps.ankiweb.net or Flathub (net.ankiweb.Anki)"); + await exec("systemd-run", ["--user", "--collect", "--quiet", `--unit=anki-${Date.now()}`, path]); + return "Starting Anki…"; +} + +export async function sync(ctx: Pick): Promise { + await anki(ctx, "sync"); + return "Anki synced with AnkiWeb"; +} + +// --- Empty state ------------------------------------------------------------- + +export async function offlineItems(ctx: Pick): Promise { + const installed = await findAnki(); + const osRelease = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const steps = [ + "## Connect Anki", + "", + "Backslash talks to Anki through the **AnkiConnect** add-on, while Anki is open.", + "", + "1. In Anki: **Tools → Add-ons → Get Add-ons…**", + `2. Paste the code **${ANKICONNECT_CODE}** and press OK`, + "3. Restart Anki, and keep it open (it can stay minimized)", + "", + "If you set an API key in AnkiConnect's config, enter it in **Settings → Extensions → Anki**.", + ]; + const items: Item[] = [{ + id: "offline", + title: installed ? "Anki isn't running, or AnkiConnect isn't installed" : "Anki isn't installed", + subtitle: installed ? `Add-on code ${ANKICONNECT_CODE}` : "Install Anki, then the AnkiConnect add-on", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: installed + ? steps.join("\n") + : ["## Install Anki", "", ...installHint(osRelease), "", ...steps.slice(2)].join("\n"), + }, + actions: [ + ...(installed + ? [action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external })] + : [open("https://apps.ankiweb.net", "Download Anki", { icon: Icons.download })]), + open(ANKICONNECT_URL, "AnkiConnect on AnkiWeb", { icon: Icons.globe }), + copy(ANKICONNECT_CODE, "Copy Add-on Code", { shortcut: "c" }), + ], + }]; + return items; +} + +// --- Adding cards ------------------------------------------------------------ + +export async function lastDeck(): Promise { + const d = await storage.get("lastDeck").catch(() => undefined); + return typeof d === "string" ? d : undefined; +} + +async function lastModel(): Promise { + const d = await storage.get("lastModel").catch(() => undefined); + return typeof d === "string" ? d : undefined; +} + +export async function addCard( + ctx: Pick, + o: { deck: string; model: string; front: string; back: string; tags: string[]; rememberModel?: boolean }, +): Promise { + const names = await anki(ctx, "modelFieldNames", { modelName: o.model }); + const fields = noteFields(names, toHtml(o.front), toHtml(o.back)); + const id = await anki(ctx, "addNote", noteParams({ deck: o.deck, model: o.model, fields, tags: o.tags })); + await storage.set("lastDeck", o.deck); + // Quick adds pick their type from the text: only the form's choice is remembered. + if (o.rememberModel) await storage.set("lastModel", o.model); + return id; +} + +/** The deck to add to: the remembered one if it still exists, else the first one that isn't empty "Default". */ +export function pickDeck(decks: string[], remembered: string | undefined): string { + if (remembered && decks.includes(remembered)) return remembered; + return decks.find((d) => d !== "Default") ?? decks[0] ?? "Default"; +} + +export async function addCardForm( + ctx: Pick, + o: { deck?: string; front?: string; standalone?: boolean } = {}, +): Promise { + const [decks, models, remembered, model] = await Promise.all([ + anki(ctx, "deckNames"), + anki(ctx, "modelNames").then(orderModels, () => [BASIC, REVERSED, CLOZE]), + lastDeck(), + lastModel(), + ]); + decks.sort((a, b) => a.localeCompare(b)); + return form({ + title: "Add Card", + submitTitle: "Add Card", + fields: [ + field.dropdown("deck", "Deck", decks.map((d) => ({ value: d, title: d.replace(/::/g, " › ") })), { + value: o.deck ?? pickDeck(decks, remembered), + }), + field.dropdown("model", "Note type", models, { + value: model && models.includes(model) ? model : models[0], + }), + field.textArea("front", "Front", { + required: true, + placeholder: "Question, or a cloze: The capital of France is {{c1::Paris}}", + value: o.front ?? "", + }), + field.textArea("back", "Back", { placeholder: "Answer (Back Extra for a cloze)" }), + field.text("tags", "Tags", { placeholder: "french vocabulary" }), + ], + async submit(values) { + const front = String(values.front ?? "").trim(); + if (!front) throw new Error("The front of the card is empty"); + const modelName = String(values.model || BASIC); + if (/cloze/i.test(modelName) && !/\{\{c\d+::/.test(front)) { + throw new Error("A cloze card needs a deletion: {{c1::hidden text}}"); + } + const deck = String(values.deck || pickDeck(decks, remembered)); + await addCard(ctx, { + deck, + model: modelName, + front, + back: String(values.back ?? ""), + tags: parseTags(String(values.tags ?? "")), + rememberModel: true, + }); + await toast(`Card added to ${deck.replace(/::/g, " › ")}`, { style: "success" }); + return o.standalone ? close(`Added to ${deck.replace(/::/g, " › ")}`) : pop(); + }, + }); +} + +export function ankiActions(ctx: Pick): Action[] { + return [ + action("Sync", async () => close(await sync(ctx)), { + icon: Icons.refresh, + shortcut: "s", + section: "Anki", + }), + action("Open Anki", async () => close(await openAnki(ctx)), { icon: Icons.external, section: "Anki" }), + ]; +} diff --git a/extensions/anki/backslash.toml b/extensions/anki/backslash.toml new file mode 100644 index 0000000..3d96ee7 --- /dev/null +++ b/extensions/anki/backslash.toml @@ -0,0 +1,66 @@ +[extension] +id = "anki" +title = "Anki" +description = "Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect" +version = "1.0.0" +author = "Backslash" +icon = "bs-layers" +license = "MIT" + +# AnkiConnect listens on 127.0.0.1:8765 while Anki runs. When Anki is +# closed, "Open Anki" starts it in the background with systemd-run; the read +# paths are only checked to find where Anki is installed (package, official +# installer, pipx, Flatpak system or user, Snap). +[permissions] +net = ["127.0.0.1:8765"] +run = ["systemd-run"] +read = [ + "/etc/os-release", + "/usr/bin/anki", + "/usr/local/bin/anki", + "~/.local/bin/anki", + "/var/lib/flatpak/exports/bin/net.ankiweb.Anki", + "~/.local/share/flatpak/exports/bin/net.ankiweb.Anki", + "/snap/bin/anki", +] +env = ["HOME"] + +[[commands]] +name = "decks" +title = "Anki Decks" +description = "New, learning and due cards per deck; study, add cards, sync" +icon = "bs-layers" +keywords = ["anki", "flashcards", "decks", "review", "study", "srs", "cartes", "réviser", "paquets", "mémoriser"] + +[[commands]] +name = "add" +title = "Add Anki Card" +description = "Deck, note type (Basic, reversed, Cloze), front, back and tags" +icon = "bs-plus" +keywords = ["anki", "card", "add", "new", "flashcard", "cloze", "carte", "ajouter", "nouvelle"] + +[[commands]] +name = "quick-add" +title = "Quick Add Anki Card" +description = "“front :: back #tag” into the last deck used; a {{c1::cloze}} makes a Cloze card" +icon = "bs-bolt" +mode = "no-view" +keywords = ["anki", "quick", "add", "card", "capture", "carte", "rapide", "ajouter"] +[[commands.arguments]] +name = "card" +placeholder = "bonjour :: hello #french" +required = true + +[[commands]] +name = "sync" +title = "Sync Anki" +description = "Sync your collection with AnkiWeb" +icon = "bs-refresh" +mode = "no-view" +keywords = ["anki", "sync", "ankiweb", "synchroniser"] + +[[preferences]] +name = "apiKey" +title = "AnkiConnect API key" +description = "Only if you set “apiKey” in AnkiConnect's config (Tools → Add-ons → AnkiConnect → Config)" +type = "secret" diff --git a/extensions/anki/commands/add.ts b/extensions/anki/commands/add.ts new file mode 100644 index 0000000..e199071 --- /dev/null +++ b/extensions/anki/commands/add.ts @@ -0,0 +1,11 @@ +import { type Context, list } from "@backslash/api"; +import { addCardForm, OfflineError, offlineItems } from "../anki.ts"; + +export default async (ctx: Context) => { + try { + return await addCardForm(ctx, { front: ctx.query.trim() || undefined, standalone: true }); + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + return list({ items: await offlineItems(ctx) }); + } +}; diff --git a/extensions/anki/commands/decks.ts b/extensions/anki/commands/decks.ts new file mode 100644 index 0000000..254456d --- /dev/null +++ b/extensions/anki/commands/decks.ts @@ -0,0 +1,91 @@ +import { + action, + close, + type Context, + copy, + Icons, + type Item, + list, + meta, + refresh, + updateCommandMetadata, +} from "@backslash/api"; +import { type Deck, dueSubtitle, dueToday, plural } from "../lib.ts"; +import { addCardForm, anki, ankiActions, loadDecks, OfflineError, offlineItems } from "../anki.ts"; + +let footer = ""; + +function deckItem(ctx: Context, d: Deck): Item { + const due = d.reviewCount + d.learnCount; + const todo = due + d.newCount; + return { + id: String(d.id), + title: d.leaf, + subtitle: d.parent || undefined, + icon: { source: d.depth ? Icons.layers : Icons.book, tint: todo ? "blue" : "gray" }, + keywords: d.name.split("::"), + accessories: [ + { text: String(d.newCount), color: d.newCount ? "blue" : "gray", tooltip: "New" }, + { text: String(d.learnCount), color: d.learnCount ? "red" : "gray", tooltip: "Learning" }, + { text: String(d.reviewCount), color: d.reviewCount ? "green" : "gray", tooltip: "To review" }, + ], + detail: { + stats: [ + { label: "New", value: d.newCount, color: "blue" }, + { label: "Learning", value: d.learnCount, color: "red" }, + { label: "To Review", value: d.reviewCount, color: "green" }, + ], + metadata: [ + meta.text("Deck", d.name.replace(/::/g, " › "), { icon: Icons.book }), + meta.text("Cards", d.total.toLocaleString("en"), { icon: Icons.layers }), + meta.text("Today", todo ? `${plural(todo, "card")} to study` : "Nothing left: well done", { + icon: todo ? Icons.clock : Icons.done, + color: todo ? undefined : "green", + }), + meta.separator(), + meta.text("Note", "Counts follow the deck's daily limits and include its subdecks"), + ], + }, + actions: [ + action("Study Now", async () => { + await anki(ctx, "guiDeckReview", { name: d.name }); + return close(`Studying ${d.leaf}`); + }, { icon: Icons.play }), + action("Add Card to Deck", () => addCardForm(ctx, { deck: d.name }), { icon: Icons.plus, shortcut: "n" }), + action("Open Deck Overview", async () => { + await anki(ctx, "guiDeckOverview", { name: d.name }); + return close(); + }, { icon: Icons.eye, shortcut: "o" }), + action("Browse Cards", async () => { + await anki(ctx, "guiBrowse", { query: `"deck:${d.name.replace(/"/g, '\\"')}"` }); + return close(); + }, { icon: Icons.search, shortcut: "b" }), + copy(d.name, "Copy Deck Name", { shortcut: "c", section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "Anki" }), + ...ankiActions(ctx), + ], + }; +} + +export default list({ + placeholder: "Search decks…", + empty: { title: "No deck", icon: Icons.book }, + status: () => footer, + async items(ctx) { + try { + const decks = await loadDecks(ctx, ctx.signal); + const subtitle = dueSubtitle(decks); + updateCommandMetadata({ subtitle: subtitle ?? "Nothing due today" }).catch(() => {}); + const t = dueToday(decks); + footer = `${plural(t.review + t.learn, "card")} due today · ${plural(t.new, "new card")} · ${ + plural(decks.length, "deck") + }`; + return decks.map((d) => deckItem(ctx, d)); + } catch (e) { + if (!(e instanceof OfflineError)) throw e; + footer = "AnkiConnect: 127.0.0.1:8765"; + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return offlineItems(ctx); + } + }, +}); diff --git a/extensions/anki/commands/quick-add.ts b/extensions/anki/commands/quick-add.ts new file mode 100644 index 0000000..eb97581 --- /dev/null +++ b/extensions/anki/commands/quick-add.ts @@ -0,0 +1,27 @@ +import { run } from "@backslash/api"; +import { BASIC, CLOZE, orderModels, parseQuickAdd } from "../lib.ts"; +import { addCard, anki, lastDeck, OfflineError, pickDeck } from "../anki.ts"; + +// "Quick Add Anki Card bonjour :: hello #french" → a Basic card in the last deck used. +export default run<{ card: string }>(async (ctx) => { + const card = parseQuickAdd(String(ctx.args.card ?? ctx.query ?? "")); + try { + const [decks, remembered, models] = await Promise.all([ + anki(ctx, "deckNames"), + lastDeck(), + anki(ctx, "modelNames"), + ]); + const deck = pickDeck(decks, remembered); + const model = card.cloze + ? (models.includes(CLOZE) ? CLOZE : models.find((m) => /cloze/i.test(m))) + : (models.includes(BASIC) ? BASIC : orderModels(models).find((m) => !/cloze/i.test(m))); + if (!model) throw new Error(`No ${card.cloze ? "Cloze" : "Basic"} note type in this collection: use Add Anki Card`); + await addCard(ctx, { deck, model, front: card.front, back: card.back, tags: card.tags }); + return `Added to ${deck.replace(/::/g, " › ")}`; + } catch (e) { + if (e instanceof OfflineError) { + throw new Error("Open Anki with the AnkiConnect add-on (code 2055492159) to add cards"); + } + throw e; + } +}); diff --git a/extensions/anki/commands/sync.ts b/extensions/anki/commands/sync.ts new file mode 100644 index 0000000..ffba9f7 --- /dev/null +++ b/extensions/anki/commands/sync.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { OfflineError, sync } from "../anki.ts"; + +export default run(async (ctx) => { + try { + return await sync(ctx); + } catch (e) { + if (e instanceof OfflineError) throw new Error("Open Anki with the AnkiConnect add-on (code 2055492159) to sync"); + throw e; + } +}); diff --git a/extensions/anki/lib.ts b/extensions/anki/lib.ts new file mode 100644 index 0000000..97954db --- /dev/null +++ b/extensions/anki/lib.ts @@ -0,0 +1,235 @@ +// AnkiConnect (https://foosoft.net/projects/anki-connect/) responses and +// Anki-specific text handling. Pure functions, no I/O. + +export const ANKICONNECT_CODE = "2055492159"; +export const ANKICONNECT_URL = "https://ankiweb.net/shared/info/2055492159"; +export const ENDPOINT = "http://127.0.0.1:8765"; + +export interface DeckStats { + deck_id: number; + name: string; + new_count: number; + learn_count: number; + review_count: number; + total_in_deck: number; +} + +export interface Deck { + id: number; + /** Full name, `Languages::Japanese::Kanji`. */ + name: string; + /** Last part, `Kanji`. */ + leaf: string; + /** `Languages › Japanese`, or empty for a top-level deck. */ + parent: string; + depth: number; + newCount: number; + learnCount: number; + reviewCount: number; + total: number; +} + +/** The request body AnkiConnect expects. */ +export function request(action: string, params: Record = {}, key?: string) { + return { action, version: 6, params, ...(key ? { key } : {}) }; +} + +/** The result of an AnkiConnect reply, or its error thrown. */ +export function unwrap(reply: unknown): T { + if (!reply || typeof reply !== "object" || !("result" in reply) || !("error" in reply)) { + throw new Error("Unexpected answer from AnkiConnect"); + } + const r = reply as { result: T; error: string | null }; + if (r.error) throw new Error(friendlyError(r.error)); + return r.result; +} + +export function friendlyError(error: string): string { + if (/duplicate/i.test(error)) return "This card already exists in Anki (same first field)"; + if (/valid api key/i.test(error)) return "AnkiConnect wants an API key: set it in Settings → Extensions → Anki"; + if (/deck was not found|deck not found/i.test(error)) return "That deck doesn't exist in Anki any more"; + if (/model was not found/i.test(error)) return "That note type doesn't exist in Anki"; + if (/empty/i.test(error)) return "The first field of the card is empty"; + return `Anki: ${error}`; +} + +/** Decks from `deckNamesAndIds` and `getDeckStats`, in Anki's tree order. */ +export function buildDecks(names: Record, stats: Record): Deck[] { + const byId = new Map(Object.values(stats ?? {}).map((s) => [s.deck_id, s])); + return Object.entries(names ?? {}) + .map(([name, id]) => { + const parts = name.split("::"); + const s = byId.get(id); + return { + id, + name, + leaf: parts[parts.length - 1], + parent: parts.slice(0, -1).join(" › "), + depth: parts.length - 1, + newCount: s?.new_count ?? 0, + learnCount: s?.learn_count ?? 0, + reviewCount: s?.review_count ?? 0, + total: s?.total_in_deck ?? 0, + }; + }) + .sort((a, b) => compareDeckNames(a.name, b.name)); +} + +/** Tree order: `A`, `A::B`, `A::C`, `B`, case-insensitive like Anki. */ +export function compareDeckNames(a: string, b: string): number { + const pa = a.toLowerCase().split("::"); + const pb = b.toLowerCase().split("::"); + for (let i = 0; i < Math.min(pa.length, pb.length); i++) { + const c = pa[i].localeCompare(pb[i]); + if (c) return c; + } + return pa.length - pb.length; +} + +/** Anki hides the empty "Default" deck when there are others. */ +export function visibleDecks(decks: Deck[]): Deck[] { + if (decks.length <= 1) return decks; + return decks.filter((d) => !(d.id === 1 && d.total === 0 && !decks.some((x) => x.name.startsWith("Default::")))); +} + +/** Cards to study today: counts of parent decks include their children, so only top-level decks are summed. */ +export function dueToday(decks: Deck[]): { review: number; learn: number; new: number } { + const top = decks.filter((d) => d.depth === 0); + return { + review: top.reduce((n, d) => n + d.reviewCount, 0), + learn: top.reduce((n, d) => n + d.learnCount, 0), + new: top.reduce((n, d) => n + d.newCount, 0), + }; +} + +/** "42 due today", "42 due today · 10 new", or undefined when there is nothing to do. */ +export function dueSubtitle(decks: Deck[]): string | undefined { + const d = dueToday(decks); + const due = d.review + d.learn; + if (!due && !d.new) return undefined; + return [due ? `${due} due today` : "", d.new ? `${d.new} new` : ""].filter(Boolean).join(" · "); +} + +// --- Notes ------------------------------------------------------------------- + +export const BASIC = "Basic"; +export const REVERSED = "Basic (and reversed card)"; +export const CLOZE = "Cloze"; + +export const isCloze = (text: string) => /\{\{c\d+::/.test(text); + +/** Note types for the form: the three standard ones first, then the user's own. */ +export function orderModels(models: string[]): string[] { + const standard = [BASIC, REVERSED, CLOZE].filter((m) => models.includes(m)); + return [...standard, ...models.filter((m) => !standard.includes(m)).sort((a, b) => a.localeCompare(b))]; +} + +/** `tag1, tag2 #tag3` → `["tag1", "tag2", "tag3"]`; Anki tags have no spaces. */ +export function parseTags(input: string): string[] { + return [...new Set(input.split(/[\s,]+/).map((t) => t.replace(/^#/, "")).filter(Boolean))]; +} + +export interface QuickCard { + front: string; + back: string; + tags: string[]; + cloze: boolean; +} + +/** + * `front :: back #tag`: the first ` :: ` outside a `{{c1::…}}` cloze splits + * the sides; `#words` at the end are tags. A text with a cloze and no back + * is a cloze card. + */ +export function parseQuickAdd(input: string): QuickCard { + let text = input.trim(); + const tags: string[] = []; + for (;;) { + const m = /\s+#([^\s#]+)$/.exec(text); + if (!m) break; + tags.unshift(m[1]); + text = text.slice(0, m.index).trimEnd(); + } + // A spaced " :: " wins, so `std::vector :: dynamic array` keeps its `::`. + const at = (spaced: boolean) => { + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text.startsWith("{{", i)) { + depth++; + i++; + } else if (text.startsWith("}}", i)) { + depth = Math.max(0, depth - 1); + i++; + } else if ( + depth === 0 && text.startsWith("::", i) && + (!spaced || (/\s/.test(text[i - 1] ?? "") && /\s/.test(text[i + 2] ?? ""))) + ) { + return i; + } + } + return -1; + }; + const spacedAt = at(true); + const split = spacedAt >= 0 ? spacedAt : at(false); + const front = (split < 0 ? text : text.slice(0, split)).trim(); + const back = split < 0 ? "" : text.slice(split + 2).trim(); + const cloze = isCloze(front); + if (!front) throw new Error("Type the front of the card: “front :: back”"); + if (!back && !cloze) throw new Error("Separate the two sides with “::”, e.g. “bonjour :: hello”"); + return { front, back, tags, cloze }; +} + +/** The fields of a note: the first two fields of the note type get the front and back. */ +export function noteFields(fieldNames: string[], front: string, back: string): Record { + const names = fieldNames.length ? fieldNames : ["Front", "Back"]; + const fields: Record = {}; + for (const n of names) fields[n] = ""; + fields[names[0]] = front; + if (names[1]) fields[names[1]] = back; + return fields; +} + +/** Plain text typed in the launcher, as Anki's HTML fields: escaped, line breaks kept. */ +export function toHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\r?\n/g, "
"); +} + +export function noteParams(o: { deck: string; model: string; fields: Record; tags: string[] }) { + return { + note: { + deckName: o.deck, + modelName: o.model, + fields: o.fields, + tags: o.tags, + options: { allowDuplicate: false, duplicateScope: "deck" }, + }, + }; +} + +// --- Installing Anki --------------------------------------------------------- + +/** `ID` and `ID_LIKE` of /etc/os-release. */ +export function osIds(osRelease: string): string[] { + const get = (k: string) => new RegExp(`^${k}=(.*)$`, "m").exec(osRelease)?.[1]?.replace(/^["']|["']$/g, "") ?? ""; + return `${get("ID")} ${get("ID_LIKE")}`.toLowerCase().split(/\s+/).filter(Boolean); +} + +/** How to install Anki on this distribution; Flathub always works and is up to date. */ +export function installHint(osRelease: string): string[] { + const ids = osIds(osRelease); + const lines = ["- Flathub (any distribution): `flatpak install flathub net.ankiweb.Anki`"]; + if (ids.some((i) => ["arch", "manjaro", "endeavouros"].includes(i))) lines.push("- Arch (AUR): `yay -S anki-bin`"); + else if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) lines.push("- Fedora: `sudo dnf install anki`"); + else if (ids.some((i) => ["debian", "ubuntu"].includes(i))) { + lines.push("- Debian, Ubuntu: `sudo apt install anki` (often an older version)"); + } else if (ids.some((i) => ["opensuse", "suse"].includes(i))) lines.push("- openSUSE: `sudo zypper install anki`"); + else if (ids.includes("nixos")) lines.push("- NixOS: add `anki` to your packages"); + lines.push("- Or the official build from [apps.ankiweb.net](https://apps.ankiweb.net)"); + return lines; +} + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; diff --git a/extensions/anki/lib_test.ts b/extensions/anki/lib_test.ts new file mode 100644 index 0000000..dcad697 --- /dev/null +++ b/extensions/anki/lib_test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + buildDecks, + compareDeckNames, + dueSubtitle, + dueToday, + friendlyError, + installHint, + noteFields, + noteParams, + orderModels, + parseQuickAdd, + parseTags, + request, + toHtml, + unwrap, + visibleDecks, +} from "./lib.ts"; +import { pickDeck } from "./anki.ts"; + +// What AnkiConnect 6 answers for a small collection. +const NAMES = { + "Default": 1, + "Languages": 1700000000001, + "Languages::Japanese": 1700000000002, + "Geography": 1700000000003, + "languages::French": 1700000000004, +}; +const STATS = { + "1": { deck_id: 1, name: "Default", new_count: 0, learn_count: 0, review_count: 0, total_in_deck: 0 }, + "1700000000001": { + deck_id: 1700000000001, + name: "Languages", + new_count: 20, + learn_count: 3, + review_count: 41, + total_in_deck: 900, + }, + "1700000000002": { + deck_id: 1700000000002, + name: "Languages::Japanese", + new_count: 10, + learn_count: 2, + review_count: 30, + total_in_deck: 600, + }, + "1700000000004": { + deck_id: 1700000000004, + name: "languages::French", + new_count: 10, + learn_count: 1, + review_count: 11, + total_in_deck: 300, + }, + "1700000000003": { + deck_id: 1700000000003, + name: "Geography", + new_count: 5, + learn_count: 0, + review_count: 7, + total_in_deck: 250, + }, +}; + +Deno.test("requests and replies", () => { + assertEquals(request("deckNames"), { action: "deckNames", version: 6, params: {} }); + assertEquals(request("sync", {}, "k"), { action: "sync", version: 6, params: {}, key: "k" }); + assertEquals(unwrap({ result: 5, error: null }), 5); + assertThrows( + () => unwrap({ result: null, error: "cannot create note because it is a duplicate" }), + Error, + "already exists", + ); + assertThrows(() => unwrap({ nope: 1 }), Error, "Unexpected"); + assertEquals( + friendlyError("valid api key must be provided"), + "AnkiConnect wants an API key: set it in Settings → Extensions → Anki", + ); + assertEquals(friendlyError("collection is not available"), "Anki: collection is not available"); +}); + +Deno.test("decks in tree order with their counts", () => { + const decks = buildDecks(NAMES, STATS); + assertEquals(decks.map((d) => d.name), [ + "Default", + "Geography", + "Languages", + "languages::French", + "Languages::Japanese", + ]); + const jp = decks.find((d) => d.leaf === "Japanese")!; + assertEquals([jp.parent, jp.depth, jp.newCount, jp.learnCount, jp.reviewCount, jp.total], [ + "Languages", + 1, + 10, + 2, + 30, + 600, + ]); + // The empty Default deck is hidden, as in Anki. + assertEquals(visibleDecks(decks).map((d) => d.leaf), ["Geography", "Languages", "French", "Japanese"]); + assertEquals(visibleDecks(buildDecks({ Default: 1 }, {})).length, 1); + assertEquals(compareDeckNames("A::B", "A"), 1); + assertEquals(compareDeckNames("a", "B") < 0, true); +}); + +Deno.test("due today counts top-level decks only", () => { + const decks = buildDecks(NAMES, STATS); + assertEquals(dueToday(decks), { review: 48, learn: 3, new: 25 }); + assertEquals(dueSubtitle(decks), "51 due today · 25 new"); + assertEquals(dueSubtitle(buildDecks({ Default: 1 }, {})), undefined); +}); + +Deno.test("quick add: sides, tags and clozes", () => { + assertEquals(parseQuickAdd("bonjour :: hello"), { front: "bonjour", back: "hello", tags: [], cloze: false }); + assertEquals(parseQuickAdd(" chat::cat #french #animals "), { + front: "chat", + back: "cat", + tags: ["french", "animals"], + cloze: false, + }); + assertEquals(parseQuickAdd("The capital of France is {{c1::Paris}}"), { + front: "The capital of France is {{c1::Paris}}", + back: "", + tags: [], + cloze: true, + }); + assertEquals(parseQuickAdd("{{c1::Paris::city}} is in {{c2::France}} :: Europe #geo"), { + front: "{{c1::Paris::city}} is in {{c2::France}}", + back: "Europe", + tags: ["geo"], + cloze: true, + }); + assertEquals(parseQuickAdd("C# :: a language").front, "C#"); + assertEquals(parseQuickAdd("std::vector :: C++ dynamic array"), { + front: "std::vector", + back: "C++ dynamic array", + tags: [], + cloze: false, + }); + assertEquals(parseQuickAdd("chat::cat").back, "cat"); + assertThrows(() => parseQuickAdd("no separator"), Error, "::"); + assertThrows(() => parseQuickAdd(":: back"), Error, "front"); +}); + +Deno.test("fields, tags, HTML and note types", () => { + assertEquals(noteFields(["Front", "Back"], "q", "a"), { Front: "q", Back: "a" }); + assertEquals(noteFields(["Text", "Back Extra"], "{{c1::x}}", ""), { Text: "{{c1::x}}", "Back Extra": "" }); + assertEquals(noteFields(["Vorderseite", "Rückseite", "Notes"], "q", "a"), { + Vorderseite: "q", + Rückseite: "a", + Notes: "", + }); + assertEquals(noteFields([], "q", "a"), { Front: "q", Back: "a" }); + assertEquals(parseTags("french, vocab #verbs french"), ["french", "vocab", "verbs"]); + assertEquals(toHtml("a < b & c\nnext"), "a < b & c
next"); + assertEquals( + orderModels(["Image Occlusion", "Cloze", "Basic (type in the answer)", "Basic", "Basic (and reversed card)"]), + [ + "Basic", + "Basic (and reversed card)", + "Cloze", + "Basic (type in the answer)", + "Image Occlusion", + ], + ); + assertEquals(noteParams({ deck: "D", model: "Basic", fields: { Front: "q" }, tags: ["t"] }), { + note: { + deckName: "D", + modelName: "Basic", + fields: { Front: "q" }, + tags: ["t"], + options: { allowDuplicate: false, duplicateScope: "deck" }, + }, + }); +}); + +Deno.test("the last deck is remembered while it exists", () => { + assertEquals(pickDeck(["Default", "French"], "French"), "French"); + assertEquals(pickDeck(["Default", "French"], "Gone"), "French"); + assertEquals(pickDeck(["Default"], undefined), "Default"); + assertEquals(pickDeck([], undefined), "Default"); +}); + +Deno.test("install hints per distribution", () => { + assertEquals(installHint("ID=fedora\nVERSION_ID=41").some((l) => l.includes("dnf")), true); + assertEquals(installHint('ID=linuxmint\nID_LIKE="ubuntu debian"').some((l) => l.includes("apt")), true); + assertEquals(installHint("ID=arch").some((l) => l.includes("anki-bin")), true); + assertEquals(installHint("").length, 2); +}); diff --git a/extensions/asana/api.ts b/extensions/asana/api.ts new file mode 100644 index 0000000..97c8b14 --- /dev/null +++ b/extensions/asana/api.ts @@ -0,0 +1,122 @@ +// Calls to the Asana REST API with the user's personal access token. + +import type { Item } from "@backslash/api"; +import { API, asanaError, AuthError, pickWorkspace, setupItem, tokenOf, type Workspace } from "./lib.ts"; + +export interface Session { + token: string; + workspace: Workspace; + me: { gid: string; name: string }; +} + +export async function asana( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify({ data: options.body }), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Asana is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined) as { data?: T } | undefined; + if (!res.ok) throw asanaError(res.status, json); + return json?.data as T; +} + +/** Follows `next_page.offset`, up to `max` results. */ +export async function paged(token: string, path: string, max: number, signal?: AbortSignal): Promise { + const out: T[] = []; + let offset = ""; + do { + const sep = path.includes("?") ? "&" : "?"; + const res = await fetch(`${API}${path}${sep}limit=100${offset ? `&offset=${encodeURIComponent(offset)}` : ""}`, { + signal, + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }).catch((e) => { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Asana is unreachable: ${(e as Error).message}`); + }); + const json = await res.json().catch(() => undefined) as + | { data?: T[]; next_page?: { offset?: string } | null } + | undefined; + if (!res.ok) throw asanaError(res.status, json); + out.push(...(json?.data ?? [])); + offset = json?.next_page?.offset ?? ""; + } while (offset && out.length < max); + return out; +} + +// One process serves the views of a command: ask who I am once. +let cached: { key: string; session: Session } | undefined; + +/** The token, the workspace from the preferences and who I am. */ +export async function session(prefs: Record, signal?: AbortSignal): Promise { + const token = tokenOf(prefs); + const key = `${token}\n${String(prefs.workspace ?? "")}`; + if (cached?.key === key) return cached.session; + const me = await asana<{ gid: string; name: string; workspaces: Workspace[] }>( + token, + "/users/me?opt_fields=name,workspaces.name,workspaces.is_organization", + { signal }, + ); + const s = { token, me: { gid: me.gid, name: me.name }, workspace: pickWorkspace(me.workspaces, prefs.workspace) }; + cached = { key, session: s }; + return s; +} + +export async function guarded( + prefs: Record, + load: (s: Session) => Promise, + signal?: AbortSignal, +): Promise { + try { + return await load(await session(prefs, signal)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} + +export const TASK_FIELDS = [ + "name", + "notes", + "completed", + "due_on", + "due_at", + "start_on", + "permalink_url", + "modified_at", + "num_subtasks", + "num_likes", + "projects.name", + "projects.color", + "tags.name", + "tags.color", + "memberships.project.name", + "memberships.section.name", +].join(","); + +export const PROJECT_FIELDS = [ + "name", + "color", + "notes", + "permalink_url", + "due_on", + "modified_at", + "owner.name", + "team.name", + "current_status_update.status_type", + "current_status_update.title", +].join(","); diff --git a/extensions/asana/backslash.toml b/extensions/asana/backslash.toml new file mode 100644 index 0000000..715ba7a --- /dev/null +++ b/extensions/asana/backslash.toml @@ -0,0 +1,43 @@ +[extension] +id = "asana" +title = "Asana" +description = "Your Asana tasks by due date and your projects; complete, reschedule and create tasks" +version = "1.0.0" +author = "Backslash" +icon = "bs-circle-check" +license = "MIT" + +[permissions] +net = ["app.asana.com"] + +[[commands]] +name = "my-tasks" +title = "My Asana Tasks" +description = "Overdue, today, upcoming and later; complete or reschedule them" +icon = "bs-circle-check" +keywords = ["asana", "tasks", "my tasks", "todo", "due", "today", "mes tâches", "tâches", "aujourd'hui", "échéance"] + +[[commands]] +name = "projects" +title = "Asana Projects" +description = "Favorites first, with their status; Enter shows the tasks" +icon = "bs-project" +keywords = ["asana", "projects", "portfolio", "status", "projets"] + +[[commands]] +name = "create-task" +title = "Create Asana Task" +description = "Title, notes, project and due date" +icon = "bs-plus" +keywords = ["asana", "new", "task", "add", "create", "nouvelle tâche", "ajouter", "créer"] + +[[preferences]] +name = "token" +title = "Personal access token" +description = "Asana → Settings → Apps → Developer apps → Create new token (app.asana.com/0/my-apps)" +type = "secret" + +[[preferences]] +name = "workspace" +title = "Workspace" +description = "The name of the workspace or organization to use; empty for your first one" diff --git a/extensions/asana/commands/create-task.ts b/extensions/asana/commands/create-task.ts new file mode 100644 index 0000000..9ee95b8 --- /dev/null +++ b/extensions/asana/commands/create-task.ts @@ -0,0 +1,15 @@ +import { type Context, detail } from "@backslash/api"; +import { session } from "../api.ts"; +import { AuthError, setupItem } from "../lib.ts"; +import { taskForm } from "../views.ts"; + +// Text typed before opening the command becomes the task name. +export default async (ctx: Context) => { + try { + return await taskForm(await session(ctx.preferences), { name: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e); + return detail({ title: "Connect Asana", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/asana/commands/my-tasks.ts b/extensions/asana/commands/my-tasks.ts new file mode 100644 index 0000000..a8b274f --- /dev/null +++ b/extensions/asana/commands/my-tasks.ts @@ -0,0 +1,53 @@ +import { action, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { guarded, paged, type Session, session, TASK_FIELDS } from "../api.ts"; +import { sortTasks, type Task, taskSection } from "../lib.ts"; +import { live } from "../live.ts"; +import { taskForm, taskItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter your tasks by name, project, tag…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "soon", title: "Due This Week" }, + ], + }, + empty: { title: "No open task", description: "Nothing assigned to you here.", icon: Icons.done }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async ({ preferences }) => taskForm(await session(preferences)), { + icon: Icons.plus, + shortcut: "n", + }), + ], + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (s: Session) => { + const tasks = await paged( + s.token, + `/tasks?assignee=me&workspace=${s.workspace.gid}&completed_since=now&opt_fields=${TASK_FIELDS}`, + 500, + signal, + ); + const now = new Date(); + const open = tasks.filter((t) => !t.completed); + const count = (name: string) => open.filter((t) => taskSection(t, now) === name).length; + const overdue = count("Overdue"), today = count("Today"); + summary = [ + `${open.length} task${open.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + today ? `${today} today` : "", + s.workspace.name, + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [overdue ? `${overdue} overdue` : "", today ? `${today} due today` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + const shown = filter === "soon" + ? open.filter((t) => ["Overdue", "Today", "Upcoming"].includes(taskSection(t, now))) + : open; + return sortTasks(shown, now).map((t) => taskItem(s, t, { section: taskSection(t, now) })); + }, signal), signal), +}); diff --git a/extensions/asana/commands/projects.ts b/extensions/asana/commands/projects.ts new file mode 100644 index 0000000..e0b1870 --- /dev/null +++ b/extensions/asana/commands/projects.ts @@ -0,0 +1,38 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { asana, guarded, paged, PROJECT_FIELDS } from "../api.ts"; +import type { Named, Project } from "../lib.ts"; +import { projectItem } from "../views.ts"; + +let total = 0; + +export default (ctx: Context) => + list({ + placeholder: "Filter projects by name, team, owner…", + empty: { title: "No project", description: "Projects of your workspace show up here.", icon: Icons.project }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : `${total} project${total === 1 ? "" : "s"}`, + items: ({ signal }) => + guarded(ctx.preferences, async (s) => { + const [all, favorites] = await Promise.all([ + paged( + s.token, + `/projects?workspace=${s.workspace.gid}&archived=false&opt_fields=${PROJECT_FIELDS}`, + 1000, + signal, + ), + asana( + s.token, + `/users/me/favorites?resource_type=project&workspace=${s.workspace.gid}&opt_fields=name`, + { signal }, + ).catch(() => [] as Named[]), + ]); + total = all.length; + const fav = new Map(favorites.map((f, i) => [f.gid, i])); + return [...all] + .sort((a, b) => + (fav.get(a.gid) ?? 1e9) - (fav.get(b.gid) ?? 1e9) || + (a.team?.name ?? "").localeCompare(b.team?.name ?? "") || + (b.modified_at ?? "").localeCompare(a.modified_at ?? "") + ) + .map((p) => projectItem(s, p, ctx.preferences, fav.has(p.gid))); + }, signal), + }); diff --git a/extensions/asana/lib.ts b/extensions/asana/lib.ts new file mode 100644 index 0000000..0399f8f --- /dev/null +++ b/extensions/asana/lib.ts @@ -0,0 +1,377 @@ +// Asana: account, response types and pure mapping (due sections, colors, metadata). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://app.asana.com/api/1.0"; +export const TOKEN_URL = "https://app.asana.com/0/my-apps"; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Asana account"); + return token; +} + +/** `{ errors: [{ message }] }` → an Error. */ +export function asanaError(status: number, body: unknown): Error { + const message = (body as { errors?: { message?: string }[] } | undefined)?.errors?.[0]?.message ?? ""; + if (status === 401) return new AuthError("Asana rejected the personal access token"); + if (status === 402) return new Error(`Asana: ${message || "this needs a paid plan"}`); + if (status === 403) return new Error(`Asana refused it${message ? `: ${message}` : ""}`); + if (status === 404) return new Error("Not found on Asana: it may have been deleted"); + if (status === 429) return new Error("Asana is rate limiting: try again in a minute"); + return new Error(`Asana answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Named { + gid: string; + name: string; +} + +export interface Workspace extends Named { + is_organization?: boolean; +} + +export interface Task extends Named { + notes?: string; + completed?: boolean; + due_on?: string | null; + due_at?: string | null; + start_on?: string | null; + permalink_url: string; + modified_at?: string; + num_subtasks?: number; + num_likes?: number; + projects?: (Named & { color?: string | null })[]; + tags?: (Named & { color?: string | null })[]; + memberships?: { project?: Named; section?: Named }[]; + assignee?: Named | null; +} + +export interface Project extends Named { + color?: string | null; + notes?: string; + permalink_url?: string; + due_on?: string | null; + modified_at?: string; + owner?: Named | null; + team?: Named | null; + archived?: boolean; + current_status_update?: { status_type?: string; title?: string } | null; +} + +/** The workspace named in the preferences (case-insensitive), or the first one. */ +export function pickWorkspace(workspaces: Workspace[], wanted: unknown): Workspace { + if (!workspaces.length) throw new Error("This Asana account has no workspace"); + const name = String(wanted ?? "").trim().toLowerCase(); + if (!name) return workspaces[0]; + const found = workspaces.find((w) => w.name.toLowerCase() === name || w.gid === name) ?? + workspaces.find((w) => w.name.toLowerCase().includes(name)); + if (!found) { + throw new Error(`No Asana workspace “${String(wanted).trim()}”: ${workspaces.map((w) => w.name).join(", ")}`); + } + return found; +} + +// --------------------------------------------------------------------------- +// Colors + +/** Asana's colors (`dark-green`, `light-warm-gray`…) in the launcher's palette. */ +export function asanaColor(color: string | null | undefined): Color { + const c = (color ?? "").replace(/^(dark|light)-/, ""); + const map: Record = { + pink: "pink", + magenta: "pink", + green: "green", + blue: "blue", + indigo: "purple", + red: "red", + teal: "teal", + aqua: "teal", + brown: "orange", + orange: "orange", + coral: "orange", + yellow: "yellow", + purple: "purple", + "warm-gray": "gray", + }; + return map[c] ?? "gray"; +} + +/** A project's latest status update: on track, at risk… */ +export function projectStatus(p: Project): { text: string; color: Color } | undefined { + const type = p.current_status_update?.status_type; + const map: Record = { + on_track: { text: "On track", color: "green" }, + at_risk: { text: "At risk", color: "yellow" }, + off_track: { text: "Off track", color: "red" }, + on_hold: { text: "On hold", color: "blue" }, + complete: { text: "Complete", color: "purple" }, + achieved: { text: "Achieved", color: "green" }, + partial: { text: "Partially achieved", color: "yellow" }, + missed: { text: "Missed", color: "red" }, + dropped: { text: "Dropped", color: "gray" }, + }; + return type ? map[type] : undefined; +} + +// --------------------------------------------------------------------------- +// Tasks + +/** When the task is due: `due_at` (a time) or `due_on` (a day). */ +export function dueOf(task: Task): { date: Date; time: boolean } | undefined { + if (task.due_at) return { date: new Date(task.due_at), time: true }; + if (task.due_on) return { date: dayOf(task.due_on), time: false }; + return undefined; +} + +export const SECTIONS = ["Overdue", "Today", "Upcoming", "Later", "No Due Date"]; + +/** Overdue, Today, Upcoming (the next 7 days), Later, No Due Date. */ +export function taskSection(task: Task, now = new Date()): string { + const due = dueOf(task); + const bucket = dueBucket(due?.date, now, due?.time ?? false); + return bucket === "Tomorrow" || bucket === "This Week" ? "Upcoming" : bucket; +} + +export function sortTasks(tasks: Task[], now = new Date()): Task[] { + const rank = (t: Task) => SECTIONS.indexOf(taskSection(t, now)); + const when = (t: Task) => dueOf(t)?.date.getTime() ?? Infinity; + return [...tasks].sort((a, b) => + rank(a) - rank(b) || when(a) - when(b) || (b.modified_at ?? "").localeCompare(a.modified_at ?? "") + ); +} + +export function dueColor(task: Task, now = new Date()): Color | undefined { + const section = taskSection(task, now); + if (section === "Overdue") return "red"; + if (section === "Today") return "green"; + return undefined; +} + +/** The section of the task in a project ("Doing"), when it has one. */ +export function sectionIn(task: Task, projectGid?: string): string | undefined { + const m = task.memberships?.find((x) => !projectGid || x.project?.gid === projectGid); + const name = m?.section?.name; + return name && name !== "(no section)" && name !== "Untitled section" ? name : undefined; +} + +export function taskMetadata(task: Task, now = new Date()): Metadata[] { + const lines: Metadata[] = []; + const due = dueOf(task); + lines.push( + due + ? meta.text("Due", shortDue(due.date, now, due.time), { icon: Icons.calendar, color: dueColor(task, now) }) + : meta.text("Due", "No due date", { icon: Icons.calendar }), + ); + if (task.start_on) { + lines.push(meta.text("Start", shortDue(dayOf(task.start_on), now, false), { icon: Icons.calendar })); + } + const projects = task.projects ?? []; + if (projects.length) { + lines.push(meta.tags("Projects", projects.map((p) => ({ text: p.name, color: asanaColor(p.color) })))); + } + const section = sectionIn(task); + if (section) lines.push(meta.text("Section", section, { icon: Icons.layers })); + const tags = task.tags ?? []; + if (tags.length) lines.push(meta.tags("Tags", tags.map((t) => ({ text: t.name, color: asanaColor(t.color) })))); + if (task.num_subtasks) lines.push(meta.text("Subtasks", task.num_subtasks, { icon: Icons.squareCheck })); + if (task.num_likes) lines.push(meta.text("Likes", task.num_likes, { icon: Icons.heart })); + lines.push(meta.separator()); + if (task.modified_at) lines.push(meta.text("Modified", shortDue(new Date(task.modified_at), now, false))); + lines.push(meta.link("Link", "Open in Asana", task.permalink_url)); + return lines; +} + +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +/** The task as Asana wants it for a due date: a day, a precise time, or none. */ +export function dueBody(when: When | undefined): Record { + if (!when) return { due_on: null, due_at: null }; + return when.time ? { due_at: when.date.toISOString() } : { due_on: localDate(when.date) }; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A personal access token from Asana", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Asana", + "", + "1. Open **Asana → Settings → Apps → Developer apps** (Enter opens it) and choose", + " **Create new token**. Give it a name like *Backslash*.", + "2. Paste the token in **Settings → Extensions → Asana → Configure → Personal access token**.", + "3. With several workspaces, type the one to use in **Workspace** (the first one otherwise).", + "", + "The token acts as you: Backslash only reads your tasks and projects and changes the tasks you act on.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Access Token", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/asana/lib_test.ts b/extensions/asana/lib_test.ts new file mode 100644 index 0000000..cae956e --- /dev/null +++ b/extensions/asana/lib_test.ts @@ -0,0 +1,163 @@ +import { assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + asanaColor, + asanaError, + AuthError, + dueBody, + dueBucket, + dueChoices, + dueColor, + dueOf, + parseWhen, + pickWorkspace, + projectStatus, + sectionIn, + setupItem, + shortDue, + sortTasks, + type Task, + taskMetadata, + taskSection, + tokenOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({ token: " " }), AuthError, "Connect"); + assertEquals(tokenOf({ token: " 2/1200/1208:abc " }), "2/1200/1208:abc"); + assertInstanceOf(asanaError(401, { errors: [{ message: "Not Authorized" }] }), AuthError); + assertEquals( + asanaError(400, { errors: [{ message: "due_on: Invalid date" }] }).message, + "Asana answered 400: due_on: Invalid date", + ); + assertStringIncludes(asanaError(404, undefined).message, "Not found"); + assertStringIncludes(setupItem(new AuthError("Connect your Asana account")).detail!.markdown!, "Create new token"); +}); + +Deno.test("workspace from the preferences", () => { + const ws = [{ gid: "1", name: "Acme" }, { gid: "2", name: "Personal Projects" }]; + assertEquals(pickWorkspace(ws, "").gid, "1"); + assertEquals(pickWorkspace(ws, "personal projects").gid, "2"); + assertEquals(pickWorkspace(ws, "personal").gid, "2"); + assertEquals(pickWorkspace(ws, "2").gid, "2"); + assertThrows(() => pickWorkspace(ws, "Globex"), Error, "Acme, Personal Projects"); + assertThrows(() => pickWorkspace([], ""), Error, "no workspace"); +}); + +Deno.test("colors and project status", () => { + assertEquals(asanaColor("dark-green"), "green"); + assertEquals(asanaColor("light-warm-gray"), "gray"); + assertEquals(asanaColor("dark-brown"), "orange"); + assertEquals(asanaColor(null), "gray"); + assertEquals(projectStatus({ gid: "p", name: "P", current_status_update: { status_type: "at_risk" } }), { + text: "At risk", + color: "yellow", + }); + assertEquals(projectStatus({ gid: "p", name: "P" }), undefined); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const task = (over: Partial): Task => ({ + gid: "t", + name: "Task", + permalink_url: "https://app.asana.com/0/0/1", + ...over, +}); + +Deno.test("tasks: due, sections, order", () => { + const late = task({ gid: "late", due_on: "2026-10-26" }); + const today = task({ gid: "today", due_on: "2026-10-27" }); + const todayAt = task({ gid: "at", due_at: new Date(2026, 9, 27, 17, 0).toISOString() }); + const pastAt = task({ gid: "pastAt", due_at: new Date(2026, 9, 27, 9, 0).toISOString() }); + const soon = task({ gid: "soon", due_on: "2026-10-30" }); + const later = task({ gid: "later", due_on: "2026-12-01" }); + const none = task({ gid: "none" }); + assertEquals(dueOf(today), { date: new Date(2026, 9, 27), time: false }); + assertEquals(taskSection(late, NOW), "Overdue"); + assertEquals(taskSection(today, NOW), "Today"); + assertEquals(taskSection(todayAt, NOW), "Today"); + assertEquals(taskSection(pastAt, NOW), "Overdue"); + assertEquals(taskSection(soon, NOW), "Upcoming"); + assertEquals(taskSection(later, NOW), "Later"); + assertEquals(taskSection(none, NOW), "No Due Date"); + assertEquals( + sortTasks([none, later, soon, todayAt, today, late, pastAt], NOW).map((t) => t.gid), + ["late", "pastAt", "today", "at", "soon", "later", "none"], + ); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(today, NOW), "green"); + assertEquals(dueColor(soon, NOW), undefined); +}); + +Deno.test("task metadata and sections", () => { + const t = task({ + due_at: new Date(2026, 9, 28, 9, 30).toISOString(), + projects: [{ gid: "p1", name: "Website", color: "dark-blue" }], + tags: [{ gid: "g1", name: "urgent", color: "dark-red" }], + memberships: [{ project: { gid: "p1", name: "Website" }, section: { gid: "s1", name: "In progress" } }], + num_subtasks: 3, + }); + const lines = taskMetadata(t, NOW); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Due")?.value, "tomorrow 09:30"); + assertEquals(by("Projects")?.tags, [{ text: "Website", color: "blue" }]); + assertEquals(by("Section")?.value, "In progress"); + assertEquals(by("Tags")?.tags, [{ text: "urgent", color: "red" }]); + assertEquals(by("Subtasks")?.value, "3"); + assertEquals(sectionIn(t, "p1"), "In progress"); + assertEquals( + sectionIn( + task({ memberships: [{ project: { gid: "p", name: "P" }, section: { gid: "s", name: "Untitled section" } }] }), + ), + undefined, + ); +}); + +Deno.test("due body for the API", () => { + assertEquals(dueBody(undefined), { due_on: null, due_at: null }); + assertEquals(dueBody(parseWhen("friday", NOW)), { due_on: "2026-10-30" }); + assertEquals(dueBody(parseWhen("friday 3pm", NOW)), { due_at: new Date(2026, 9, 30, 15).toISOString() }); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/asana/live.ts b/extensions/asana/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/asana/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/asana/views.ts b/extensions/asana/views.ts new file mode 100644 index 0000000..5b7c2e2 --- /dev/null +++ b/extensions/asana/views.ts @@ -0,0 +1,262 @@ +// Items, actions and forms shared by the commands. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { asana, guarded, paged, type Session, TASK_FIELDS } from "./api.ts"; +import { + asanaColor, + dayOf, + dueBody, + dueChoices, + dueColor, + dueOf, + excerpt, + parseWhen, + type Project, + projectStatus, + sectionIn, + shortDue, + type Task, + taskMetadata, +} from "./lib.ts"; +import { live, notifyChanged } from "./live.ts"; + +async function updateTask(s: Session, task: Task, body: Record, message: string) { + await asana(s.token, `/tasks/${task.gid}`, { method: "PUT", body }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function taskActions(s: Session, task: Task): Action[] { + const reschedule = submenu( + "Change Due Date", + dueChoices().map((c) => + action( + c.title, + () => + updateTask( + s, + task, + c.date ? { due_on: c.date } : { due_on: null, due_at: null }, + c.date ? `Due ${shortDue(dayOf(c.date), new Date(), false)}` : "Due date removed", + ), + { icon: c.date ? Icons.calendar : Icons.cross }, + ) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ); + return [ + open(task.permalink_url, "Open in Asana"), + task.completed + ? action("Mark Incomplete", () => updateTask(s, task, { completed: false }, "Marked incomplete"), { + icon: Icons.todo, + }) + : action("Complete Task", () => updateTask(s, task, { completed: true }, `Completed “${task.name}”`), { + icon: Icons.done, + }), + reschedule, + action("New Task…", () => taskForm(s), { icon: Icons.plus, shortcut: "n", section: "Task" }), + copy(task.permalink_url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${task.name}](${task.permalink_url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(task.name, "Copy Title", { shortcut: "period", section: "Copy" }), + ]; +} + +export function taskItem(s: Session, task: Task, options: { section?: string; project?: string } = {}): Item { + const now = new Date(); + const due = dueOf(task); + const projects = (task.projects ?? []).filter((p) => p.gid !== options.project); + const tags = task.tags ?? []; + const where = options.project ? undefined : sectionIn(task); + return { + id: task.gid, + title: task.name || "Untitled task", + subtitle: where, + icon: task.completed + ? { source: Icons.done, tint: "green" } + : { source: Icons.todo, tint: dueColor(task, now) ?? "gray" }, + section: options.section, + keywords: [...(task.projects ?? []).map((p) => p.name), ...tags.map((t) => t.name)], + accessories: [ + ...tags.slice(0, 1).map((t) => ({ tag: t.name, color: asanaColor(t.color) })), + ...projects.slice(0, 1).map((p) => ({ + icon: { source: Icons.dot, tint: asanaColor(p.color) }, + text: p.name, + tooltip: "Project", + })), + ...(task.num_subtasks ? [{ icon: Icons.squareCheck, text: String(task.num_subtasks), tooltip: "Subtasks" }] : []), + ...(due + ? [{ + icon: Icons.calendar, + text: shortDue(due.date, now, due.time), + color: dueColor(task, now), + tooltip: "Due", + }] + : []), + ], + detail: { markdown: excerpt(task.notes), metadata: taskMetadata(task, now) }, + actions: taskActions(s, task), + }; +} + +/** The open tasks of a project, by section, live after a change. */ +export function projectTasks(s: Session, project: Project, prefs: Record): ListSpec { + return list({ + title: project.name, + placeholder: `Filter tasks of ${project.name}…`, + empty: { title: "No open task in this project", icon: Icons.done }, + status: (items) => `${items.length} open task${items.length === 1 ? "" : "s"}`, + actions: [ + action("New Task in Project…", () => taskForm(s, { project: project.gid }), { + icon: Icons.plus, + shortcut: "n", + }), + ...(project.permalink_url ? [open(project.permalink_url, "Open Project in Asana")] : []), + ], + items: ({ signal }) => + live(() => + guarded(prefs, async () => { + const tasks = await paged( + s.token, + `/projects/${project.gid}/tasks?completed_since=now&opt_fields=${TASK_FIELDS}`, + 500, + signal, + ); + // Asana returns them in the project's order, section by section. + return tasks.map((t) => + taskItem(s, t, { section: sectionIn(t, project.gid) ?? "Tasks", project: project.gid }) + ); + }, signal), signal), + }); +} + +export function projectItem(s: Session, p: Project, prefs: Record, favorite: boolean): Item { + const status = projectStatus(p); + return { + id: p.gid, + title: p.name, + // Sections are teams; favorites say which team. + subtitle: favorite ? p.team?.name : p.current_status_update?.title || undefined, + icon: { source: Icons.project, tint: asanaColor(p.color) }, + section: favorite ? "Favorites" : p.team?.name ?? "Projects", + keywords: [p.owner?.name ?? "", p.team?.name ?? ""].filter(Boolean), + accessories: [ + ...(favorite ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Favorite" }] : []), + ...(status ? [{ tag: status.text, color: status.color }] : []), + ...(p.owner ? [{ avatar: p.owner.name, tooltip: `Owner: ${p.owner.name}` }] : []), + ], + detail: { + markdown: excerpt(p.notes), + metadata: [ + ...(status + ? [ + meta.text("Status", status.text, { icon: Icons.activity, color: status.color }), + ...(p.current_status_update?.title ? [meta.text("Update", p.current_status_update.title)] : []), + ] + : []), + ...(p.owner ? [meta.person("Owner", p.owner.name)] : []), + ...(p.team ? [meta.text("Team", p.team.name, { icon: Icons.person })] : []), + ...(p.due_on ? [meta.text("Due", shortDue(dayOf(p.due_on), new Date(), false), { icon: Icons.calendar })] : []), + meta.separator(), + ...(p.modified_at ? [meta.text("Modified", shortDue(new Date(p.modified_at), new Date(), false))] : []), + ...(p.permalink_url ? [meta.link("Link", "Open in Asana", p.permalink_url)] : []), + ], + }, + actions: [ + action("Show Tasks", () => projectTasks(s, p, prefs), { icon: Icons.layers }), + ...(p.permalink_url ? [open(p.permalink_url, "Open in Asana")] : []), + action("New Task in Project…", () => taskForm(s, { project: p.gid }), { icon: Icons.plus, shortcut: "n" }), + ...(p.permalink_url ? [copy(p.permalink_url, "Copy Link", { shortcut: "c", section: "Copy" })] : []), + ], + }; +} + +export async function projects(s: Session, signal?: AbortSignal): Promise { + return await paged( + s.token, + `/projects?workspace=${s.workspace.gid}&archived=false&opt_fields=name,color`, + 500, + signal, + ); +} + +/** A new task; `standalone` when the form is the command itself (nothing to go back to). */ +export async function taskForm( + s: Session, + options: { project?: string; name?: string; standalone?: boolean } = {}, +) { + const all = await projects(s).catch(() => [] as Project[]); + const last = await storage.get("lastProject").catch(() => undefined); + const initial = options.project ?? (all.some((p) => p.gid === last) ? last! : ""); + return form({ + title: "New Asana task", + submitTitle: "Create Task", + fields: [ + field.text("name", "Task name", { required: true, placeholder: "What needs doing?", value: options.name ?? "" }), + field.textArea("notes", "Description"), + field.dropdown( + "project", + "Project", + [ + { value: "", title: "No project (My Tasks)" }, + ...[...all].sort((a, b) => a.name.localeCompare(b.name)) + .map((p) => ({ value: p.gid, title: p.name })), + ], + { value: initial }, + ), + field.text("due", "Due", { placeholder: "today, friday 3pm, next week, +3d, 2026-11-03…" }), + field.checkbox("me", "Assign to me", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const project = String(values.project ?? ""); + const task = await asana(s.token, "/tasks?opt_fields=name,permalink_url", { + method: "POST", + body: { + name: String(values.name).trim(), + notes: String(values.notes ?? "").trim() || undefined, + workspace: s.workspace.gid, + ...(project ? { projects: [project] } : {}), + ...(values.me ? { assignee: "me" } : {}), + ...(when ? dueBody(when) : {}), + }, + }); + await storage.set("lastProject", project).catch(() => {}); + notifyChanged(); + await toast(`Created “${task.name}”`, { style: "success" }); + if (!options.standalone) return pop(); + const projectName = all.find((p) => p.gid === project)?.name; + return detail({ + title: task.name, + markdown: `# ${task.name}\n\nCreated in **${projectName ?? "My Tasks"}**.`, + metadata: [ + meta.text("Workspace", s.workspace.name), + ...(projectName ? [meta.text("Project", projectName, { icon: Icons.project })] : []), + ...(when ? [meta.text("Due", shortDue(when.date, new Date(), when.time), { icon: Icons.calendar })] : []), + meta.link("Link", "Open in Asana", task.permalink_url), + ], + status: "Task created", + actions: [ + open(task.permalink_url, "Open in Asana"), + copy(task.permalink_url, "Copy Link", { shortcut: "c" }), + ], + }); + }, + }); +} diff --git a/extensions/aws/aws.ts b/extensions/aws/aws.ts new file mode 100644 index 0000000..cdf1cf1 --- /dev/null +++ b/extensions/aws/aws.ts @@ -0,0 +1,229 @@ +// Calling the aws CLI with the profile picked in Backslash, and the items +// every command shares (missing CLI, errors, profile switching). + +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + open, + refresh, + storage, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + activeProfile, + classifyError, + installCommand, + osFamily, + parseProfiles, + type Profile, + regionsFor, +} from "./lib.ts"; +import { holdOpen, runInTerminal } from "./terminal.ts"; + +export interface Prefs { + regions?: string; + terminal?: string; +} + +const HOME = Deno.env.get("HOME") ?? ""; +const read = (path: string) => Deno.readTextFile(path).catch(() => ""); + +export interface Session { + profiles: Profile[]; + /** The profile commands run with; undefined when there is no config at all. */ + profile?: string; + /** Where the choice came from. */ + origin: "backslash" | "env" | "default"; + regions: string[]; +} + +export async function session(prefs: Prefs): Promise { + const [config, credentials, picked] = await Promise.all([ + read(`${HOME}/.aws/config`), + read(`${HOME}/.aws/credentials`), + storage.get("profile").catch(() => undefined), + ]); + const profiles = parseProfiles(config, credentials); + const env = Deno.env.get("AWS_PROFILE") || undefined; + const profile = activeProfile(profiles, picked ?? undefined, env); + const origin = picked && profile === picked ? "backslash" : env && profile === env ? "env" : "default"; + const current = profiles.find((p) => p.name === profile); + const envRegion = Deno.env.get("AWS_REGION") || Deno.env.get("AWS_DEFAULT_REGION") || undefined; + return { profiles, profile, origin, regions: regionsFor(prefs.regions, current?.region, envRegion) }; +} + +/** Runs `aws` with the profile and region, JSON output and no pager. */ +export function aws( + args: string[], + o: { profile?: string; region?: string; signal?: AbortSignal; json?: boolean } = {}, +): Promise { + return exec("aws", [ + ...args, + ...(o.profile ? ["--profile", o.profile] : []), + ...(o.region ? ["--region", o.region] : []), + ...(o.json === false ? [] : ["--output", "json"]), + "--no-cli-pager", + ], { signal: o.signal }); +} + +/** The same command line, for terminals and "Copy Command". */ +export const awsArgv = (args: string[], profile?: string, region?: string) => [ + "aws", + ...args, + ...(profile ? ["--profile", profile] : []), + ...(region ? ["--region", region] : []), +]; + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +export async function useProfile(name: string) { + await storage.set("profile", name); + await toast(`Backslash now uses ${name}`, { + style: "success", + message: "Your shells keep their own AWS_PROFILE", + }); +} + +export async function showProfile(s: Session, extra?: string) { + await updateCommandMetadata({ + subtitle: s.profile ? [s.profile, s.regions.join(", "), extra].filter(Boolean).join(" · ") : null, + }).catch(() => {}); +} + +export function inTerminal( + title: string, + argv: string[], + prefs: Prefs, + o: { icon?: string; shortcut?: string; section?: string; hold?: boolean } = {}, +): Action { + return action(title, () => { + const term = runInTerminal(o.hold ? holdOpen(argv) : argv, prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: Icons.terminal, shortcut: o.shortcut, section: o.section ?? "Terminal" }); +} + +/** "Switch Profile ›", loaded when opened. */ +export function profileActions(prefs: Prefs): Action[] { + return [ + submenu("Switch Profile", async () => { + const s = await session(prefs); + return s.profiles.map((p) => + action(p.name, async () => { + await useProfile(p.name); + return refresh(); + }, { icon: p.name === s.profile ? Icons.check : Icons.person }) + ); + }, { icon: Icons.person, shortcut: "p", section: "Profile" }), + ]; +} + +let family: Promise | undefined; +export const installHint = () => (family ??= read("/etc/os-release").then((t) => installCommand(osFamily(t)))); + +export async function awsMissing(): Promise { + const cmd = await installHint(); + return { + id: "missing", + title: "The AWS CLI is not installed", + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension runs the **aws** command (AWS CLI v2) with your `~/.aws/config` profiles.", + "", + "```sh", + cmd, + "aws configure sso # or: aws configure", + "```", + "", + "SSM sessions also need the **Session Manager plugin**.", + ].join("\n"), + }, + actions: [ + copy(cmd, "Copy Install Command", { icon: Icons.terminal }), + open( + "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html", + "Open Install Guide", + ), + ], + }; +} + +export function noProfiles(prefs: Prefs): Item { + return { + id: "no-profile", + title: "No AWS profile yet", + subtitle: "Configure one with aws configure sso or aws configure", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "No profile in `~/.aws/config` or `~/.aws/credentials`.", + "", + "- **IAM Identity Center (SSO)**: `aws configure sso`", + "- **Access keys**: `aws configure`", + ].join("\n"), + }, + actions: [ + inTerminal("Run aws configure sso", ["aws", "configure", "sso"], prefs, { section: "Setup" }), + inTerminal("Run aws configure", ["aws", "configure"], prefs, { section: "Setup" }), + ], + }; +} + +/** An error as an item, with the way out (SSO login, configure, switch profile). */ +export function errorItem(err: unknown, s: Session, prefs: Prefs, region?: string): Item { + const e = classifyError(err); + const p = s.profile; + const fixes: Action[] = []; + if ( + e.problem === "expired" || (e.problem === "credentials" && s.profiles.find((x) => x.name === p)?.kind === "sso") + ) { + fixes.push(inTerminal("Sign In with aws sso login", awsArgv(["sso", "login"], p), prefs, { + icon: Icons.key, + section: "Fix", + hold: true, + })); + } else if (e.problem === "credentials") { + fixes.push(inTerminal("Run aws configure", awsArgv(["configure"], p), prefs, { icon: Icons.key, section: "Fix" })); + } + return { + id: `error:${region ?? ""}`, + title: e.title, + subtitle: [p, region].filter(Boolean).join(" · "), + icon: { source: Icons.alert, tint: e.problem === "denied" ? "orange" : "red" }, + section: region ? `Errors` : undefined, + detail: { markdown: `\`\`\`\n${e.message}\n\`\`\`` }, + actions: [ + ...fixes, + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + copy(e.message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Runs one call per region in parallel; failed regions become error items. */ +export async function perRegion( + s: Session, + prefs: Prefs, + load: (region: string) => Promise, +): Promise<{ results: T[]; errors: Item[] }> { + const settled = await Promise.allSettled(s.regions.map(load)); + const results: T[] = []; + const errors: Item[] = []; + settled.forEach((r, i) => { + if (r.status === "fulfilled") results.push(...r.value); + else if (isMissing(r.reason)) throw r.reason; + else errors.push(errorItem(r.reason, s, prefs, s.regions[i])); + }); + // The same problem in every region (expired SSO…) is shown once. + const unique = errors.filter((e, i) => errors.findIndex((x) => x.title === e.title) === i); + return { results, errors: unique }; +} diff --git a/extensions/aws/backslash.toml b/extensions/aws/backslash.toml new file mode 100644 index 0000000..5f8d7b2 --- /dev/null +++ b/extensions/aws/backslash.toml @@ -0,0 +1,67 @@ +[extension] +id = "aws" +title = "AWS" +description = "Switch AWS profiles, start and stop EC2 instances, open SSM sessions, browse S3 buckets and tail CloudWatch logs with the aws CLI" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +# The aws CLI does the signing and reads its own credentials; the extension +# only reads the profile list (never the secret keys it shows masked). +[permissions] +env = ["HOME", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION"] +read = ["~/.aws/config", "~/.aws/credentials", "/etc/os-release"] +run = [ + "aws", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "profiles" +title = "AWS Profiles" +description = "Profiles of ~/.aws/config and credentials; pick the one Backslash uses, SSO login, who am I" +icon = "bs-user" +keywords = ["aws", "amazon", "profile", "sso", "credentials", "account", "switch", "profil", "compte", "identifiants"] + +[[commands]] +name = "ec2" +title = "EC2 Instances" +description = "Instances with their state, type and IPs: SSM session, start, stop, reboot, console" +icon = "bs-server" +keywords = ["aws", "ec2", "instance", "vm", "server", "ssm", "amazon", "serveur", "machine virtuelle"] + +[[commands]] +name = "s3" +title = "S3 Buckets" +description = "Buckets and their objects: open in the console, copy URIs and pre-signed URLs" +icon = "bs-archive" +keywords = ["aws", "s3", "bucket", "storage", "objects", "files", "amazon", "stockage", "fichiers"] + +[[commands]] +name = "logs" +title = "CloudWatch Log Groups" +description = "Log groups with their size and retention: tail in a terminal or show the last hour" +icon = "bs-text" +keywords = ["aws", "cloudwatch", "logs", "lambda", "tail", "amazon", "journaux", "surveillance"] + +[[preferences]] +name = "regions" +title = "Regions" +description = "Regions for EC2 and CloudWatch, comma-separated (eu-west-3, us-east-1). Empty: the profile's region" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For SSM sessions, logs and SSO login. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/aws/commands/ec2.ts b/extensions/aws/commands/ec2.ts new file mode 100644 index 0000000..0e6dd36 --- /dev/null +++ b/extensions/aws/commands/ec2.ts @@ -0,0 +1,155 @@ +import { type Action, action, copy, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { + age, + capitalize, + ec2ConsoleUrl, + filterInstances, + formatDate, + type Instance, + type InstanceFilter, + parseInstances, + sortInstances, + STATE_COLORS, +} from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + inTerminal, + isMissing, + noProfiles, + perRegion, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; +import { shellLine } from "../terminal.ts"; + +function changeState(i: Instance, s: Session, verb: "start" | "stop" | "reboot"): Action { + const label = i.name ?? i.id; + const titles = { start: "Start Instance", stop: "Stop Instance", reboot: "Reboot Instance" }; + const icons = { start: Icons.play, stop: Icons.stop, reboot: Icons.refresh }; + return action(titles[verb], async () => { + await aws(["ec2", `${verb}-instances`, "--instance-ids", i.id], { profile: s.profile, region: i.region }); + await toast(`${verb === "start" ? "Starting" : verb === "stop" ? "Stopping" : "Rebooting"} ${label}`, { + style: "success", + }); + return refresh(); + }, { + icon: icons[verb], + confirm: verb === "start" ? undefined : `${capitalize(verb)} ${label} (${i.id})?`, + destructive: verb !== "start", + section: "Instance", + shortcut: verb === "start" ? "s" : verb === "stop" ? "x" : undefined, + }); +} + +function item(i: Instance, s: Session, prefs: Prefs, multiRegion: boolean): Item { + const color = STATE_COLORS[i.state]; + const ssm = awsArgv(["ssm", "start-session", "--target", i.id], s.profile, i.region); + const actions: Action[] = []; + if (i.state === "running") { + actions.push(inTerminal("Start SSM Session", ssm, prefs, { icon: Icons.terminal, section: "Terminal" })); + } + actions.push(open(ec2ConsoleUrl(i), "Open in AWS Console", { icon: Icons.globe, shortcut: "o" })); + actions.push( + copy(i.id, "Copy Instance ID", { shortcut: "period", section: "Copy" }), + ...(i.publicIp ? [copy(i.publicIp, "Copy Public IP", { icon: Icons.globe, section: "Copy" })] : []), + ...(i.privateIp ? [copy(i.privateIp, "Copy Private IP", { icon: Icons.server, section: "Copy" })] : []), + ...(i.publicDns ? [copy(i.publicDns, "Copy Public DNS", { icon: Icons.link, section: "Copy" })] : []), + copy(shellLine(ssm), "Copy SSM Command", { icon: Icons.terminal, section: "Copy" }), + ); + if (i.state === "stopped") actions.push(changeState(i, s, "start")); + if (i.state === "running") actions.push(changeState(i, s, "stop"), changeState(i, s, "reboot")); + actions.push( + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ); + + const extraTags = Object.entries(i.tags).filter(([k]) => k !== "Name" && !k.startsWith("aws:")).slice(0, 8); + return { + id: `${i.region}/${i.id}`, + title: i.name ?? i.id, + subtitle: i.name ? i.id : undefined, + icon: { source: Icons.server, tint: color }, + section: multiRegion ? i.region : capitalize(i.state), + keywords: [i.id, i.type, i.state, i.privateIp ?? "", i.publicIp ?? "", i.region, ...Object.values(i.tags)], + accessories: [ + ...(i.publicIp ? [{ icon: Icons.globe, text: i.publicIp, tooltip: "Public IP" }] : []), + { text: i.type, tooltip: "Instance type" }, + { tag: capitalize(i.state), color }, + ], + detail: { + stats: [ + { label: "state", value: capitalize(i.state), color }, + { label: "type", value: i.type }, + { label: "up for", value: i.state === "running" ? age(i.launched) || "—" : "—" }, + ], + metadata: [ + meta.text("Instance ID", i.id, { icon: Icons.hash }), + ...(i.publicIp ? [meta.text("Public IP", i.publicIp, { icon: Icons.globe })] : []), + ...(i.privateIp ? [meta.text("Private IP", i.privateIp, { icon: Icons.server })] : []), + ...(i.publicDns ? [meta.text("Public DNS", i.publicDns)] : []), + meta.text("Zone", i.zone ?? i.region, { icon: Icons.pin }), + meta.text("Platform", `${i.platform}${i.architecture ? ` · ${i.architecture}` : ""}`, { icon: Icons.cpu }), + ...(i.imageId ? [meta.text("AMI", i.imageId)] : []), + ...(i.keyName ? [meta.text("Key pair", i.keyName, { icon: Icons.key })] : []), + ...(i.iamProfile ? [meta.text("IAM profile", i.iamProfile, { icon: Icons.shield })] : []), + ...(i.securityGroups.length ? [meta.tags("Security groups", i.securityGroups)] : []), + meta.separator(), + ...(i.vpcId ? [meta.text("VPC", i.vpcId, { icon: Icons.layers })] : []), + ...(i.subnetId ? [meta.text("Subnet", i.subnetId)] : []), + ...(extraTags.length ? [meta.tags("Tags", extraTags.map(([k, v]) => `${k}=${v}`))] : []), + ...(i.launched ? [meta.text("Launched", formatDate(i.launched), { icon: Icons.calendar })] : []), + ...(i.stateReason && i.state !== "running" ? [meta.text("Reason", i.stateReason, { icon: Icons.info })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ], + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter instances by name, ID, IP, type, tag…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "stopped", title: "Stopped" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "No instances", description: "Nothing matches in these regions", icon: Icons.server }, + status: () => summary, + async items({ preferences, filter, signal }) { + const prefs = preferences as Prefs; + summary = ""; + const s = await session(prefs); + let loaded; + try { + loaded = await perRegion(s, prefs, async (region) => + parseInstances( + await aws(["ec2", "describe-instances"], { profile: s.profile, region, signal }), + region, + )); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + throw err; + } + // Without any profile, credentials may still come from the environment. + if (!s.profiles.length && loaded.errors.length) return [noProfiles(prefs)]; + const all = loaded.results; + const running = all.filter((i) => i.state === "running").length; + summary = `${s.profile ?? "default"} · ${s.regions.join(", ")} · ${all.length} instance${ + all.length === 1 ? "" : "s" + } · ${running} running`; + await showProfile(s, loaded.errors.length ? undefined : `${running} running`); + const multi = s.regions.length > 1; + const shown = sortInstances(filterInstances(all, (filter ?? "all") as InstanceFilter)); + return [...loaded.errors, ...shown.map((i) => item(i, s, prefs, multi))]; + }, +}); diff --git a/extensions/aws/commands/logs.ts b/extensions/aws/commands/logs.ts new file mode 100644 index 0000000..de08b50 --- /dev/null +++ b/extensions/aws/commands/logs.ts @@ -0,0 +1,116 @@ +import { action, copy, detail, type DetailSpec, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; +import { age, bytes, formatDate, type LogGroup, logGroupConsoleUrl, logSource, parseLogGroups } from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + inTerminal, + isMissing, + noProfiles, + perRegion, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; +import { shellLine } from "../terminal.ts"; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(no events in the last hour)"}\n\`\`\``; + +async function recent(g: LogGroup, s: Session, prefs: Prefs): Promise { + const text = await aws(["logs", "tail", g.name, "--since", "1h", "--format", "short"], { + profile: s.profile, + region: g.region, + json: false, + }).catch((e) => String(e.message)); + const lines = text.split("\n"); + const last = lines.slice(-200).join("\n"); + return detail({ + title: g.name, + markdown: fence(last), + status: `Last hour · ${lines.length > 200 ? `last 200 of ${lines.length}` : lines.length} lines`, + metadata: metadata(g, s), + actions: [ + inTerminal("Follow in Terminal", tailArgv(g, s), prefs, { shortcut: "t" }), + copy(last, "Copy Events", { shortcut: "c" }), + action("Reload", () => recent(g, s, prefs), { icon: Icons.refresh, shortcut: "r" }), + ], + }); +} + +const tailArgv = (g: LogGroup, s: Session) => + awsArgv(["logs", "tail", g.name, "--follow", "--since", "10m", "--format", "short"], s.profile, g.region); + +function metadata(g: LogGroup, s: Session) { + return [ + meta.text("Source", logSource(g.name), { icon: Icons.layers }), + meta.text("Stored", bytes(g.storedBytes), { icon: Icons.archive }), + meta.text("Retention", g.retentionDays ? `${g.retentionDays} days` : "Never expires", { + icon: Icons.clock, + color: g.retentionDays ? undefined : "orange", + }), + ...(g.className ? [meta.tags("Class", [g.className])] : []), + meta.separator(), + meta.text("Region", g.region, { icon: Icons.globe }), + ...(g.created ? [meta.text("Created", formatDate(g.created), { icon: Icons.calendar })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ]; +} + +function item(g: LogGroup, s: Session, prefs: Prefs): Item { + const tail = tailArgv(g, s); + return { + id: `${g.region}:${g.name}`, + title: g.name, + icon: { source: Icons.text, tint: "purple" }, + section: s.regions.length > 1 ? g.region : logSource(g.name), + keywords: [logSource(g.name), g.region], + accessories: [ + { text: bytes(g.storedBytes), tooltip: "Stored" }, + ...(g.retentionDays ? [{ icon: Icons.clock, text: `${g.retentionDays}d`, tooltip: "Retention" }] : []), + ...(g.created ? [{ text: age(g.created), tooltip: `Created ${formatDate(g.created)}` }] : []), + ], + detail: { metadata: metadata(g, s) }, + actions: [ + inTerminal("Tail in Terminal", tail, prefs, { section: "Logs" }), + action("Show Last Hour", () => recent(g, s, prefs), { icon: Icons.text, shortcut: "l", section: "Logs" }), + open(logGroupConsoleUrl(g), "Open in AWS Console", { icon: Icons.globe, shortcut: "o" }), + copy(g.name, "Copy Log Group Name", { shortcut: "period", section: "Copy" }), + copy(shellLine(tail), "Copy Tail Command", { icon: Icons.terminal, section: "Copy" }), + ...(g.arn ? [copy(g.arn, "Copy ARN", { section: "Copy" })] : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter log groups…", + empty: { title: "No log groups", description: "Nothing in these regions", icon: Icons.text }, + status: () => summary, + async items({ preferences, signal }) { + const prefs = preferences as Prefs; + summary = ""; + const s = await session(prefs); + let loaded; + try { + loaded = await perRegion(s, prefs, async (region) => + parseLogGroups( + await aws(["logs", "describe-log-groups"], { profile: s.profile, region, signal }), + region, + )); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + throw err; + } + if (!s.profiles.length && loaded.errors.length) return [noProfiles(prefs)]; + const groups = loaded.results.sort((a, b) => a.name.localeCompare(b.name)); + const total = groups.reduce((n, g) => n + g.storedBytes, 0); + summary = `${s.profile ?? "default"} · ${s.regions.join(", ")} · ${groups.length} log groups · ${bytes(total)}`; + await showProfile(s); + return [...loaded.errors, ...groups.map((g) => item(g, s, prefs))]; + }, +}); diff --git a/extensions/aws/commands/profiles.ts b/extensions/aws/commands/profiles.ts new file mode 100644 index 0000000..e22e101 --- /dev/null +++ b/extensions/aws/commands/profiles.ts @@ -0,0 +1,152 @@ +import { action, copy, detail, Icons, type Item, list, meta, type Metadata, open, refresh } from "@backslash/api"; +import { consoleHome, KIND_NAMES, maskKey, type Profile, type ProfileKind } from "../lib.ts"; +import { + aws, + awsArgv, + awsMissing, + errorItem, + inTerminal, + isMissing, + noProfiles, + type Prefs, + session, + showProfile, + useProfile, +} from "../aws.ts"; + +const KIND_ICONS: Record = { + sso: Icons.shield, + role: Icons.person, + keys: Icons.key, + process: Icons.terminal, + "web-identity": Icons.globe, + other: Icons.settings, +}; + +const ORIGIN = { + backslash: "Picked in Backslash", + env: "From $AWS_PROFILE", + default: "Default profile", +}; + +const EXPLAIN = [ + "Backslash cannot change the environment of shells that are already open.", + "**Use This Profile** makes this extension's commands (EC2, S3, CloudWatch) run with `--profile`;", + "for your terminals, copy the `export AWS_PROFILE=…` line.", +].join(" "); + +async function whoAmI(p: Profile, prefs: Prefs) { + const region = p.region ?? "us-east-1"; + let markdown: string; + let metadata: Metadata[] = []; + try { + const id = JSON.parse(await aws(["sts", "get-caller-identity"], { profile: p.name, region })) as { + Account: string; + Arn: string; + UserId: string; + }; + markdown = `## ${id.Arn.split("/").slice(1).join("/") || id.Arn}\n\n\`${id.Arn}\``; + metadata = [ + meta.text("Account", id.Account, { icon: Icons.home }), + meta.text("User ID", id.UserId, { icon: Icons.person }), + meta.text("Profile", p.name, { icon: Icons.key }), + ]; + return detail({ + title: `Who am I · ${p.name}`, + markdown, + metadata, + actions: [copy(id.Account, "Copy Account ID"), copy(id.Arn, "Copy ARN")], + }); + } catch (err) { + const item = errorItem(err, { profiles: [p], profile: p.name, origin: "default", regions: [region] }, prefs); + return detail({ + title: item.title, + markdown: `## ${item.title}\n\n${item.detail?.markdown ?? ""}`, + actions: item.actions, + }); + } +} + +function item(p: Profile, current: boolean, origin: keyof typeof ORIGIN, prefs: Prefs): Item { + const lines: Metadata[] = [ + meta.text("Credentials", KIND_NAMES[p.kind], { icon: KIND_ICONS[p.kind] }), + ...(p.region ? [meta.text("Region", p.region, { icon: Icons.globe })] : []), + ...(p.ssoAccountId ? [meta.text("Account", p.ssoAccountId, { icon: Icons.home })] : []), + ...(p.ssoRoleName ? [meta.text("Role", p.ssoRoleName, { icon: Icons.person })] : []), + ...(p.ssoStartUrl ? [meta.link("SSO portal", new URL(p.ssoStartUrl).host, p.ssoStartUrl)] : []), + ...(p.ssoSession ? [meta.text("SSO session", p.ssoSession)] : []), + ...(p.roleArn ? [meta.text("Role ARN", p.roleArn, { icon: Icons.person })] : []), + ...(p.sourceProfile ? [meta.text("Source profile", p.sourceProfile)] : []), + ...(p.accessKeyId + ? [ + meta.text("Access key", maskKey(p.accessKeyId), { icon: Icons.key, color: p.temporary ? "yellow" : undefined }), + ] + : []), + ...(p.output ? [meta.text("Output", p.output)] : []), + meta.separator(), + meta.tags("Defined in", p.files.map((f) => `~/.aws/${f}`)), + ...(current ? [meta.text("Status", ORIGIN[origin], { icon: Icons.check, color: "green" })] : []), + ]; + const region = p.region ?? "us-east-1"; + return { + id: p.name, + title: p.name, + subtitle: [KIND_NAMES[p.kind], p.ssoAccountId ?? p.roleArn?.split(":")[4]].filter(Boolean).join(" · "), + icon: { source: current ? Icons.check : KIND_ICONS[p.kind], tint: current ? "green" : "orange" }, + section: current ? "Current Profile" : "Profiles", + keywords: [p.kind, p.region ?? "", p.ssoAccountId ?? "", p.ssoRoleName ?? "", p.roleArn ?? ""], + accessories: [ + ...(p.region ? [{ icon: Icons.globe, text: p.region, tooltip: "Region" }] : []), + ...(current ? [{ tag: origin === "backslash" ? "In use" : "Current", color: "green" as const }] : []), + ], + detail: { markdown: current ? EXPLAIN : undefined, metadata: lines }, + actions: [ + ...(current && origin === "backslash" ? [] : [action("Use This Profile", async () => { + await useProfile(p.name); + return refresh(); + }, { icon: Icons.arrowRight })]), + action("Who Am I", () => whoAmI(p, prefs), { icon: Icons.person, shortcut: "i" }), + ...(p.kind === "sso" + ? [inTerminal("Sign In with aws sso login", awsArgv(["sso", "login"], p.name), prefs, { + icon: Icons.key, + shortcut: "l", + hold: true, + })] + : []), + open(consoleHome(region), "Open AWS Console", { icon: Icons.globe, shortcut: "o" }), + copy(`export AWS_PROFILE=${p.name}`, "Copy export AWS_PROFILE", { + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(p.name, "Copy Profile Name", { shortcut: "period", section: "Copy" }), + ...(p.ssoAccountId ? [copy(p.ssoAccountId, "Copy Account ID", { section: "Copy" })] : []), + ...(p.roleArn ? [copy(p.roleArn, "Copy Role ARN", { section: "Copy" })] : []), + ], + }; +} + +export default list({ + placeholder: "Switch AWS profile…", + empty: { title: "No profiles", description: "Run aws configure sso or aws configure", icon: Icons.key }, + status: (items) => { + const n = items.filter((i) => !["missing", "no-profile"].includes(i.id ?? "")).length; + return n ? `${n} profile${n === 1 ? "" : "s"} · ~/.aws/config` : ""; + }, + async items({ preferences }) { + const prefs = preferences as Prefs; + const s = await session(prefs); + await showProfile(s); + if (!s.profiles.length) { + try { + await aws(["--version"], { json: false }); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + } + return [noProfiles(prefs)]; + } + return s.profiles + .map((p) => item(p, p.name === s.profile, s.origin, prefs)) + .sort((a, b) => Number(b.section === "Current Profile") - Number(a.section === "Current Profile")); + }, +}); diff --git a/extensions/aws/commands/s3.ts b/extensions/aws/commands/s3.ts new file mode 100644 index 0000000..a622efe --- /dev/null +++ b/extensions/aws/commands/s3.ts @@ -0,0 +1,186 @@ +import { + action, + clipboard, + close, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, +} from "@backslash/api"; +import { + age, + type Bucket, + bytes, + formatDate, + parseBuckets, + parseObjects, + relativeKey, + s3ConsoleUrl, + type S3Entry, +} from "../lib.ts"; +import { + aws, + awsMissing, + errorItem, + isMissing, + noProfiles, + type Prefs, + profileActions, + type Session, + session, + showProfile, +} from "../aws.ts"; + +const MAX_KEYS = 300; + +/** One level of a bucket: its "folders" (common prefixes) and objects. */ +function browse(bucket: Bucket, prefix: string, s: Session, prefs: Prefs): ListSpec { + let truncated = false; + return list({ + title: `s3://${bucket.name}/${prefix}`, + placeholder: `Filter s3://${bucket.name}/${prefix}…`, + empty: { title: "Empty", description: "No objects under this prefix", icon: Icons.folder }, + status: (items) => `${items.length}${truncated ? `+` : ""} entries · s3://${bucket.name}/${prefix}`, + async items({ signal }) { + let entries: S3Entry[]; + try { + const out = await aws([ + "s3api", + "list-objects-v2", + "--bucket", + bucket.name, + "--prefix", + prefix, + "--delimiter", + "/", + "--max-items", + String(MAX_KEYS), + ], { profile: s.profile, region: bucket.region, signal }); + entries = parseObjects(out); + truncated = /"NextToken"/.test(out); + } catch (err) { + return [errorItem(err, s, prefs, bucket.region)]; + } + return entries.map((e) => entryItem(bucket, prefix, e, s, prefs)); + }, + }); +} + +function entryItem(bucket: Bucket, prefix: string, e: S3Entry, s: Session, prefs: Prefs): Item { + const uri = `s3://${bucket.name}/${e.key}`; + const name = relativeKey(e.key, prefix); + if (e.folder) { + return { + id: e.key, + title: name, + icon: { source: Icons.folder, tint: "blue" }, + section: "Folders", + actions: [ + action("Open Folder", () => browse(bucket, e.key, s, prefs), { icon: Icons.folder }), + open(s3ConsoleUrl(bucket.name, e.key, bucket.region), "Open in AWS Console", { + icon: Icons.globe, + shortcut: "o", + }), + copy(uri, "Copy S3 URI", { shortcut: "c", section: "Copy" }), + ], + }; + } + return { + id: e.key, + title: name, + icon: { source: Icons.file, tint: "gray" }, + section: "Objects", + accessories: [ + ...(e.size !== undefined ? [{ text: bytes(e.size), tooltip: "Size" }] : []), + ...(e.modified ? [{ text: age(e.modified), tooltip: formatDate(e.modified) }] : []), + ], + detail: { + metadata: [ + meta.text("Key", e.key, { icon: Icons.file }), + ...(e.size !== undefined ? [meta.text("Size", bytes(e.size))] : []), + ...(e.modified ? [meta.text("Modified", formatDate(e.modified), { icon: Icons.calendar })] : []), + ...(e.storageClass ? [meta.tags("Storage class", [e.storageClass])] : []), + meta.text("Bucket", bucket.name, { icon: Icons.archive }), + ], + }, + actions: [ + copy(uri, "Copy S3 URI", { shortcut: "c" }), + action("Copy Pre-signed URL (1 hour)", async () => { + const url = await aws(["s3", "presign", uri, "--expires-in", "3600"], { + profile: s.profile, + region: bucket.region, + json: false, + }); + // Anyone holding it can download the object: keep it out of clipboard histories. + await clipboard.write(url, { concealed: true }); + return close("Pre-signed URL copied, valid 1 hour"); + }, { icon: Icons.link, shortcut: "l" }), + open(s3ConsoleUrl(bucket.name, prefix, bucket.region), "Open Folder in AWS Console", { + icon: Icons.globe, + shortcut: "o", + }), + copy(e.key, "Copy Key", { section: "Copy" }), + copy(`arn:aws:s3:::${bucket.name}/${e.key}`, "Copy ARN", { section: "Copy" }), + ], + }; +} + +function bucketItem(b: Bucket, s: Session, prefs: Prefs): Item { + return { + id: b.name, + title: b.name, + icon: { source: Icons.archive, tint: "green" }, + section: b.region ?? "Buckets", + keywords: [b.region ?? ""], + accessories: [ + ...(b.region ? [{ icon: Icons.globe, text: b.region, tooltip: "Region" }] : []), + ...(b.created ? [{ text: age(b.created), tooltip: `Created ${formatDate(b.created)}` }] : []), + ], + detail: { + metadata: [ + meta.text("Bucket", b.name, { icon: Icons.archive }), + ...(b.region ? [meta.text("Region", b.region, { icon: Icons.globe })] : []), + ...(b.created ? [meta.text("Created", formatDate(b.created), { icon: Icons.calendar })] : []), + meta.text("Profile", s.profile ?? "default", { icon: Icons.person }), + ], + }, + actions: [ + open(s3ConsoleUrl(b.name, "", b.region), "Open in AWS Console", { icon: Icons.globe }), + action("Browse Objects", () => browse(b, "", s, prefs), { icon: Icons.folder, shortcut: "b" }), + copy(`s3://${b.name}`, "Copy S3 URI", { shortcut: "c", section: "Copy" }), + copy(b.name, "Copy Bucket Name", { shortcut: "period", section: "Copy" }), + copy(`arn:aws:s3:::${b.name}`, "Copy ARN", { section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...profileActions(prefs), + ], + }; +} + +export default list({ + placeholder: "Filter S3 buckets…", + empty: { title: "No buckets", description: "This account has no S3 bucket", icon: Icons.archive }, + status: (items) => { + const n = items.filter((i) => !i.id?.startsWith("error") && i.id !== "missing").length; + return n ? `${n} bucket${n === 1 ? "" : "s"}` : ""; + }, + async items({ preferences, signal }) { + const prefs = preferences as Prefs; + const s = await session(prefs); + try { + // list-buckets is global; the region only picks the endpoint. + const buckets = parseBuckets( + await aws(["s3api", "list-buckets"], { profile: s.profile, region: s.regions[0], signal }), + ); + await showProfile(s, `${buckets.length} buckets`); + return buckets.map((b) => bucketItem(b, s, prefs)); + } catch (err) { + if (isMissing(err)) return [await awsMissing()]; + if (!s.profiles.length) return [noProfiles(prefs)]; + return [errorItem(err, s, prefs)]; + } + }, +}); diff --git a/extensions/aws/lib.ts b/extensions/aws/lib.ts new file mode 100644 index 0000000..f9e7224 --- /dev/null +++ b/extensions/aws/lib.ts @@ -0,0 +1,434 @@ +// Pure logic of the AWS extension: ~/.aws parsers, aws CLI output mapping, +// console URLs, error classification and formatting. No I/O here. + +// --- ~/.aws/config and ~/.aws/credentials ------------------------------------------ + +export type Ini = Map>; + +export function parseIni(text: string): Ini { + const out: Ini = new Map(); + let cur: Record | undefined; + let lastKey = ""; + for (const raw of text.split("\n")) { + const line = raw.replace(/\r$/, ""); + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue; + const head = /^\[\s*([^\]]+?)\s*\]$/.exec(trimmed); + if (head) { + cur = out.get(head[1]) ?? {}; + out.set(head[1], cur); + continue; + } + if (!cur) continue; + // Indented lines continue a nested value (`s3 =` then ` max_concurrent_requests = 20`). + if (/^\s/.test(line) && lastKey) { + cur[lastKey] = `${cur[lastKey]}\n${trimmed}`.trim(); + continue; + } + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + lastKey = trimmed.slice(0, eq).trim().toLowerCase(); + cur[lastKey] = trimmed.slice(eq + 1).trim(); + } + return out; +} + +export type ProfileKind = "sso" | "role" | "keys" | "process" | "web-identity" | "other"; + +export interface Profile { + name: string; + kind: ProfileKind; + region?: string; + output?: string; + ssoStartUrl?: string; + ssoSession?: string; + ssoAccountId?: string; + ssoRoleName?: string; + roleArn?: string; + sourceProfile?: string; + /** Static access key id, never the secret. */ + accessKeyId?: string; + /** Static keys with a session token (temporary credentials). */ + temporary?: boolean; + /** Where the profile was found. */ + files: ("config" | "credentials")[]; +} + +export function parseProfiles(configText: string, credentialsText: string): Profile[] { + const config = parseIni(configText); + const creds = parseIni(credentialsText); + const byName = new Map; creds?: Record }>(); + for (const [section, values] of config) { + const name = section === "default" ? "default" : /^profile\s+(.+)$/.exec(section)?.[1]?.trim(); + if (!name) continue; // [sso-session x], [services x]… + byName.set(name, { ...byName.get(name), config: values }); + } + for (const [name, values] of creds) byName.set(name, { ...byName.get(name), creds: values }); + const sessions = new Map>(); + for (const [section, values] of config) { + const m = /^sso-session\s+(.+)$/.exec(section); + if (m) sessions.set(m[1].trim(), values); + } + const out: Profile[] = []; + for (const [name, { config: c = {}, creds: k = {} }] of byName) { + const all = { ...k, ...c }; + const session = c.sso_session ? sessions.get(c.sso_session) : undefined; + const kind: ProfileKind = c.sso_session || c.sso_start_url + ? "sso" + : all.role_arn && all.web_identity_token_file + ? "web-identity" + : all.role_arn + ? "role" + : all.credential_process + ? "process" + : all.aws_access_key_id + ? "keys" + : "other"; + out.push({ + name, + kind, + region: all.region || session?.sso_region || undefined, + output: all.output || undefined, + ssoStartUrl: c.sso_start_url || session?.sso_start_url || undefined, + ssoSession: c.sso_session || undefined, + ssoAccountId: c.sso_account_id || undefined, + ssoRoleName: c.sso_role_name || undefined, + roleArn: all.role_arn || undefined, + sourceProfile: all.source_profile || undefined, + accessKeyId: all.aws_access_key_id || undefined, + temporary: !!all.aws_session_token, + files: [ + ...(byName.get(name)?.config ? ["config" as const] : []), + ...(k.aws_access_key_id ? ["credentials" as const] : []), + ], + }); + } + return out.sort((a, b) => + Number(b.name === "default") - Number(a.name === "default") || a.name.localeCompare(b.name) + ); +} + +export const KIND_NAMES: Record = { + sso: "IAM Identity Center (SSO)", + role: "Assumed role", + keys: "Access keys", + process: "Credential process", + "web-identity": "Web identity", + other: "Settings only", +}; + +/** `AKIAIOSFODNN7EXAMPLE` → `AKIA…MPLE`. */ +export const maskKey = (id: string) => id.length > 8 ? `${id.slice(0, 4)}…${id.slice(-4)}` : "****"; + +/** The profile used: picked in Backslash, then `$AWS_PROFILE`, then `default`, then the first. */ +export function activeProfile(profiles: Profile[], picked?: string, env?: string): string | undefined { + for (const name of [picked, env]) if (name && profiles.some((p) => p.name === name)) return name; + if (profiles.some((p) => p.name === "default")) return "default"; + return env || profiles[0]?.name; +} + +/** The regions to query: the preference (comma or space separated), else the profile's, else us-east-1. */ +export function regionsFor(pref: unknown, profileRegion?: string, envRegion?: string): string[] { + const listed = String(pref ?? "").split(/[\s,]+/).map((r) => r.trim().toLowerCase()).filter((r) => + /^[a-z]{2}(-gov)?-[a-z]+-\d$/.test(r) + ); + if (listed.length) return [...new Set(listed)]; + return [profileRegion || envRegion || "us-east-1"]; +} + +// --- EC2 --------------------------------------------------------------------------- + +export type InstanceState = "pending" | "running" | "shutting-down" | "terminated" | "stopping" | "stopped"; + +export interface Instance { + id: string; + name?: string; + state: InstanceState; + type: string; + region: string; + zone?: string; + privateIp?: string; + publicIp?: string; + publicDns?: string; + imageId?: string; + keyName?: string; + vpcId?: string; + subnetId?: string; + platform: string; + architecture?: string; + launched?: Date; + securityGroups: string[]; + tags: Record; + iamProfile?: string; + stateReason?: string; +} + +export function parseInstances(json: string, region: string): Instance[] { + const data = JSON.parse(json || "{}") as { Reservations?: { Instances?: any[] }[] }; + const out: Instance[] = []; + for (const r of data.Reservations ?? []) { + for (const i of r.Instances ?? []) { + const tags = Object.fromEntries( + ((i.Tags ?? []) as { Key: string; Value: string }[]).map((t) => [t.Key, t.Value]), + ); + out.push({ + id: i.InstanceId, + name: tags.Name || undefined, + state: (i.State?.Name ?? "pending") as InstanceState, + type: i.InstanceType ?? "", + region, + zone: i.Placement?.AvailabilityZone, + privateIp: i.PrivateIpAddress || undefined, + publicIp: i.PublicIpAddress || undefined, + publicDns: i.PublicDnsName || undefined, + imageId: i.ImageId, + keyName: i.KeyName, + vpcId: i.VpcId, + subnetId: i.SubnetId, + platform: i.PlatformDetails ?? (i.Platform === "windows" ? "Windows" : "Linux/UNIX"), + architecture: i.Architecture, + launched: i.LaunchTime ? new Date(i.LaunchTime) : undefined, + securityGroups: ((i.SecurityGroups ?? []) as { GroupName: string }[]).map((g) => g.GroupName), + tags, + iamProfile: i.IamInstanceProfile?.Arn?.split("/").pop(), + stateReason: i.StateTransitionReason || undefined, + }); + } + } + return out; +} + +export const STATE_COLORS = { + running: "green", + pending: "yellow", + stopping: "orange", + "shutting-down": "orange", + stopped: "gray", + terminated: "red", +} as const; + +const STATE_ORDER: InstanceState[] = ["running", "pending", "stopping", "shutting-down", "stopped", "terminated"]; + +export function sortInstances(all: Instance[]): Instance[] { + return [...all].sort((a, b) => + STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state) || + (a.name ?? a.id).localeCompare(b.name ?? b.id) + ); +} + +export type InstanceFilter = "running" | "stopped" | "all"; + +export function filterInstances(all: Instance[], filter: InstanceFilter): Instance[] { + if (filter === "running") return all.filter((i) => i.state === "running" || i.state === "pending"); + if (filter === "stopped") return all.filter((i) => i.state === "stopped" || i.state === "stopping"); + return all; +} + +export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, " "); + +// --- S3 ---------------------------------------------------------------------------- + +export interface Bucket { + name: string; + created?: Date; + region?: string; +} + +export function parseBuckets(json: string): Bucket[] { + const data = JSON.parse(json || "{}") as { + Buckets?: { Name: string; CreationDate?: string; BucketRegion?: string }[]; + }; + return (data.Buckets ?? []).map((b) => ({ + name: b.Name, + created: b.CreationDate ? new Date(b.CreationDate) : undefined, + region: b.BucketRegion || undefined, + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface S3Entry { + key: string; + folder: boolean; + size?: number; + modified?: Date; + storageClass?: string; +} + +export function parseObjects(json: string): S3Entry[] { + const data = JSON.parse(json || "{}") as { + CommonPrefixes?: { Prefix: string }[]; + Contents?: { Key: string; Size?: number; LastModified?: string; StorageClass?: string }[]; + }; + return [ + ...(data.CommonPrefixes ?? []).map((p) => ({ key: p.Prefix, folder: true })), + ...(data.Contents ?? []).filter((o) => !o.Key.endsWith("/")).map((o) => ({ + key: o.Key, + folder: false, + size: o.Size, + modified: o.LastModified ? new Date(o.LastModified) : undefined, + storageClass: o.StorageClass, + })), + ]; +} + +/** `logs/2026/app.log` under `logs/` → `2026/app.log`. */ +export const relativeKey = (key: string, prefix: string) => key.startsWith(prefix) ? key.slice(prefix.length) : key; + +// --- CloudWatch Logs ----------------------------------------------------------------- + +export interface LogGroup { + name: string; + region: string; + storedBytes: number; + retentionDays?: number; + created?: Date; + className?: string; + arn?: string; +} + +export function parseLogGroups(json: string, region: string): LogGroup[] { + const data = JSON.parse(json || "{}") as { logGroups?: any[] }; + return (data.logGroups ?? []).map((g) => ({ + name: g.logGroupName, + region, + storedBytes: g.storedBytes ?? 0, + retentionDays: g.retentionInDays, + created: g.creationTime ? new Date(g.creationTime) : undefined, + className: g.logGroupClass, + arn: g.logGroupArn ?? g.arn?.replace(/:\*$/, ""), + })); +} + +/** `/aws/lambda/checkout` → `Lambda`; the kind of resource writing the group. */ +export function logSource(name: string): string { + const m = /^\/aws\/([^/]+)/.exec(name); + if (!m) return "Custom"; + const known: Record = { + lambda: "Lambda", + ecs: "ECS", + eks: "EKS", + rds: "RDS", + apigateway: "API Gateway", + codebuild: "CodeBuild", + "vendedlogs": "Vended", + events: "EventBridge", + "api-gateway": "API Gateway", + }; + return known[m[1]] ?? m[1]; +} + +// --- Console URLs --------------------------------------------------------------------- + +export const ec2ConsoleUrl = (i: Instance) => + `https://${i.region}.console.aws.amazon.com/ec2/home?region=${i.region}#InstanceDetails:instanceId=${i.id}`; + +export const s3ConsoleUrl = (bucket: string, prefix = "", region?: string) => + `https://s3.console.aws.amazon.com/s3/buckets/${encodeURIComponent(bucket)}?${ + region ? `region=${region}&` : "" + }prefix=${encodeURIComponent(prefix)}&showversions=false`; + +/** The CloudWatch console escapes the group name twice, with `$` for `%`. */ +export const logGroupConsoleUrl = (g: { name: string; region: string }) => + `https://${g.region}.console.aws.amazon.com/cloudwatch/home?region=${g.region}#logsV2:log-groups/log-group/${ + encodeURIComponent(encodeURIComponent(g.name)).replace(/%/g, "$") + }`; + +export const consoleHome = (region: string) => `https://${region}.console.aws.amazon.com/console/home?region=${region}`; + +// --- Errors --------------------------------------------------------------------------- + +export type Problem = "credentials" | "expired" | "denied" | "network" | "profile" | "plugin" | "other"; + +export interface Classified { + problem: Problem; + title: string; + message: string; +} + +export function classifyError(err: unknown): Classified { + const message = (err instanceof Error ? err.message : String(err)).replace(/^aws exited with \d+: /, "").trim(); + const is = (re: RegExp) => re.test(message); + if (is(/SessionManagerPlugin is not found|session-manager-plugin/i)) { + return { problem: "plugin", title: "The Session Manager plugin is missing", message }; + } + if ( + is( + /Token has expired|SSO session .*expired|ExpiredToken|Error loading SSO Token|token .* expired|refresh failed|UnauthorizedSSOToken/i, + ) + ) { + return { problem: "expired", title: "Session expired", message }; + } + if (is(/Unable to locate credentials|NoCredentialProviders|You must specify a region|partial credentials/i)) { + return { problem: "credentials", title: "No credentials for this profile", message }; + } + if (is(/config profile \(.+\) could not be found/i)) { + return { problem: "profile", title: "Profile not found", message }; + } + if (is(/InvalidClientTokenId|SignatureDoesNotMatch|AuthFailure|security token included in the request is invalid/i)) { + return { problem: "credentials", title: "AWS rejected the credentials", message }; + } + if (is(/UnauthorizedOperation|AccessDenied|not authorized to perform/i)) { + return { problem: "denied", title: "Access denied", message }; + } + if ( + is( + /Could not connect to the endpoint|Connect timeout|Read timeout|Name or service not known|EndpointConnectionError/i, + ) + ) { + return { problem: "network", title: "Cannot reach AWS", message }; + } + return { problem: "other", title: "The aws command failed", message }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function bytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB", "PB"]; + let v = n; + let i = -1; + do { + v /= 1024; + i++; + } while (v >= 1024 && i < units.length - 1); + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = Math.max(0, (now.getTime() - date.getTime()) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; + +// --- Installing the CLI ------------------------------------------------------------------ + +export type Family = "debian" | "fedora" | "arch" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +export function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "sudo pacman -S aws-cli-v2"; + case "fedora": + return "sudo dnf install awscli2"; + case "suse": + return "sudo zypper install aws-cli"; + default: + return "sudo snap install aws-cli --classic"; + } +} diff --git a/extensions/aws/lib_test.ts b/extensions/aws/lib_test.ts new file mode 100644 index 0000000..13e668b --- /dev/null +++ b/extensions/aws/lib_test.ts @@ -0,0 +1,212 @@ +import { assertEquals } from "@std/assert"; +import { + activeProfile, + age, + bytes, + classifyError, + filterInstances, + installCommand, + logGroupConsoleUrl, + logSource, + maskKey, + osFamily, + parseBuckets, + parseIni, + parseInstances, + parseLogGroups, + parseObjects, + parseProfiles, + regionsFor, + relativeKey, + s3ConsoleUrl, + sortInstances, +} from "./lib.ts"; +import { holdOpen, terminalArgs } from "./terminal.ts"; + +const CONFIG = ` +[default] +region = eu-west-3 +output = json + +[profile prod] +sso_session = acme +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[sso-session acme] +sso_start_url = https://acme.awsapps.com/start +sso_region = eu-west-1 + +[profile ci] +role_arn = arn:aws:iam::210987654321:role/ci-deploy +source_profile = default +s3 = + max_concurrent_requests = 20 + +[services local] +s3 = + endpoint_url = http://localhost:4566 +`; + +const CREDENTIALS = ` +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +[temp] +aws_access_key_id = ASIAXXXXXXXXXXXX1234 +aws_secret_access_key = x +aws_session_token = y +`; + +Deno.test("ini: nested values and sections", () => { + const ini = parseIni(CONFIG); + assertEquals(ini.get("profile ci")?.s3, "max_concurrent_requests = 20"); + assertEquals(ini.get("sso-session acme")?.sso_region, "eu-west-1"); +}); + +Deno.test("profiles from config and credentials", () => { + const profiles = parseProfiles(CONFIG, CREDENTIALS); + assertEquals(profiles.map((p) => [p.name, p.kind]), [ + ["default", "keys"], + ["ci", "role"], + ["prod", "sso"], + ["temp", "keys"], + ]); + const prod = profiles.find((p) => p.name === "prod")!; + assertEquals(prod.ssoStartUrl, "https://acme.awsapps.com/start"); + // Falls back to the SSO region when the profile has none. + assertEquals(prod.region, "eu-west-1"); + assertEquals(profiles[0].files, ["config", "credentials"]); + assertEquals(profiles[0].accessKeyId, "AKIAIOSFODNN7EXAMPLE"); + assertEquals(profiles.find((p) => p.name === "temp")!.temporary, true); + // The secret never makes it into a profile. + assertEquals(JSON.stringify(profiles).includes("wJalrXUtnFEMI"), false); + assertEquals(maskKey("AKIAIOSFODNN7EXAMPLE"), "AKIA…MPLE"); +}); + +Deno.test("active profile: picked, then $AWS_PROFILE, then default", () => { + const profiles = parseProfiles(CONFIG, CREDENTIALS); + assertEquals(activeProfile(profiles, "prod", "ci"), "prod"); + assertEquals(activeProfile(profiles, "gone", "ci"), "ci"); + assertEquals(activeProfile(profiles, undefined, undefined), "default"); + assertEquals(activeProfile(parseProfiles("[profile a]\nregion=x\n", ""), undefined, undefined), "a"); + assertEquals(activeProfile([], undefined, undefined), undefined); +}); + +Deno.test("regions preference", () => { + assertEquals(regionsFor("eu-west-3, us-east-1 eu-west-3", "x"), ["eu-west-3", "us-east-1"]); + assertEquals(regionsFor("", "ap-south-1"), ["ap-south-1"]); + assertEquals(regionsFor("nonsense", undefined, "us-gov-west-1"), ["us-gov-west-1"]); + assertEquals(regionsFor(undefined), ["us-east-1"]); +}); + +const INSTANCES = JSON.stringify({ + Reservations: [{ + Instances: [ + { + InstanceId: "i-0a1b2c3d4e5f60718", + InstanceType: "t3.medium", + LaunchTime: "2026-10-20T08:12:00+00:00", + Placement: { AvailabilityZone: "eu-west-3a" }, + PrivateIpAddress: "10.0.1.12", + PublicIpAddress: "15.236.10.4", + PublicDnsName: "", + State: { Code: 16, Name: "running" }, + PlatformDetails: "Linux/UNIX", + SecurityGroups: [{ GroupName: "web", GroupId: "sg-1" }], + Tags: [{ Key: "Name", Value: "web-1" }, { Key: "env", Value: "prod" }], + IamInstanceProfile: { Arn: "arn:aws:iam::123456789012:instance-profile/web-role" }, + }, + { InstanceId: "i-2", InstanceType: "m6i.large", State: { Name: "stopped" }, Platform: "windows" }, + { InstanceId: "i-3", InstanceType: "t4g.nano", State: { Name: "pending" }, Tags: [{ Key: "Name", Value: "a" }] }, + ], + }], +}); + +Deno.test("EC2 instances", () => { + const all = parseInstances(INSTANCES, "eu-west-3"); + assertEquals(all[0].name, "web-1"); + assertEquals(all[0].publicDns, undefined); + assertEquals(all[0].iamProfile, "web-role"); + assertEquals(all[0].tags.env, "prod"); + assertEquals(all[1].platform, "Windows"); + assertEquals(sortInstances(all).map((i) => i.id), ["i-0a1b2c3d4e5f60718", "i-3", "i-2"]); + assertEquals(filterInstances(all, "running").map((i) => i.id), ["i-0a1b2c3d4e5f60718", "i-3"]); + assertEquals(filterInstances(all, "stopped").map((i) => i.id), ["i-2"]); + assertEquals(parseInstances("", "x"), []); +}); + +Deno.test("S3 buckets and objects", () => { + const buckets = parseBuckets(JSON.stringify({ + Buckets: [ + { Name: "zeta", CreationDate: "2024-03-01T10:00:00+00:00" }, + { Name: "acme", CreationDate: "2023-01-15T10:00:00+00:00", BucketRegion: "eu-west-3" }, + ], + })); + assertEquals(buckets.map((b) => [b.name, b.region]), [["acme", "eu-west-3"], ["zeta", undefined]]); + const objects = parseObjects(JSON.stringify({ + CommonPrefixes: [{ Prefix: "site/img/" }], + Contents: [{ Key: "site/", Size: 0 }, { Key: "site/index.html", Size: 5120, StorageClass: "STANDARD" }], + })); + assertEquals(objects.map((o) => [o.key, o.folder]), [["site/img/", true], ["site/index.html", false]]); + assertEquals(relativeKey("site/img/", "site/"), "img/"); + assertEquals( + s3ConsoleUrl("acme", "site/", "eu-west-3"), + "https://s3.console.aws.amazon.com/s3/buckets/acme?region=eu-west-3&prefix=site%2F&showversions=false", + ); +}); + +Deno.test("CloudWatch log groups", () => { + const groups = parseLogGroups( + JSON.stringify({ + logGroups: [{ + logGroupName: "/aws/lambda/checkout", + creationTime: 1700000000000, + retentionInDays: 14, + storedBytes: 52428800, + arn: "arn:aws:logs:eu-west-3:123:log-group:/aws/lambda/checkout:*", + }, { logGroupName: "app/api" }], + }), + "eu-west-3", + ); + assertEquals(groups[0].arn, "arn:aws:logs:eu-west-3:123:log-group:/aws/lambda/checkout"); + assertEquals(groups[1].storedBytes, 0); + assertEquals(logSource(groups[0].name), "Lambda"); + assertEquals(logSource("app/api"), "Custom"); + assertEquals( + logGroupConsoleUrl(groups[0]), + "https://eu-west-3.console.aws.amazon.com/cloudwatch/home?region=eu-west-3#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fcheckout", + ); +}); + +Deno.test("errors are classified with a way out", () => { + const c = (m: string) => classifyError(new Error(`aws exited with 255: ${m}`)).problem; + assertEquals(c("Error when retrieving token from sso: Token has expired and refresh failed"), "expired"); + assertEquals( + c('Unable to locate credentials. You can configure credentials by running "aws configure".'), + "credentials", + ); + assertEquals(c("The config profile (nope) could not be found"), "profile"); + assertEquals(c("An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation"), "denied"); + assertEquals(c('Could not connect to the endpoint URL: "https://ec2.eu-west-3.amazonaws.com/"'), "network"); + assertEquals(c("SessionManagerPlugin is not found. Please refer to SessionManager Documentation"), "plugin"); + assertEquals(classifyError(new Error("aws exited with 1: boom")).message, "boom"); +}); + +Deno.test("formatting", () => { + assertEquals(bytes(512), "512 B"); + assertEquals(bytes(52428800), "50.0 MB"); + assertEquals(bytes(1536 * 1024 * 1024 * 1024), "1.5 TB"); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals(age(new Date("2026-10-20T08:12:00Z"), now), "8d"); + assertEquals(age(new Date("2026-10-28T11:30:00Z"), now), "30m"); + assertEquals(age(undefined, now), ""); +}); + +Deno.test("install hints and terminals", () => { + assertEquals(installCommand(osFamily("ID=arch\n")), "sudo pacman -S aws-cli-v2"); + assertEquals(installCommand(osFamily('ID=ubuntu\nID_LIKE="debian"\n')), "sudo snap install aws-cli --classic"); + assertEquals(terminalArgs("konsole", ["aws", "sso", "login"]), ["-e", "aws", "sso", "login"]); + assertEquals(holdOpen(["aws", "sso", "login"])[0], "sh"); +}); diff --git a/extensions/aws/terminal.ts b/extensions/aws/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/aws/terminal.ts @@ -0,0 +1,89 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + env, + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/books/api.ts b/extensions/books/api.ts new file mode 100644 index 0000000..67197d9 --- /dev/null +++ b/extensions/books/api.ts @@ -0,0 +1,88 @@ +// The Open Library API (no key), and the reading list. + +import { storage } from "@backslash/api"; +import { + type Book, + fromDoc, + isbnQuery, + type Saved, + SEARCH_FIELDS, + type SearchDoc, + setStatus, + type Status, + type Work, +} from "./lib.ts"; + +const BASE = "https://openlibrary.org"; +// Open Library asks clients to identify themselves. +const HEADERS = { + Accept: "application/json", + "User-Agent": "Backslash-Books/1.0 (+https://github.com/backslash-app/plugins)", +}; + +type Json = Parameters[1]; + +export async function load(key: string): Promise { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise { + await storage.set(key, value as Json).catch(() => {}); +} + +async function getJson(path: string, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(20_000); + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + headers: HEADERS, + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Error( + timeout.aborted + ? "Open Library is taking too long to answer" + : `Open Library is unreachable: ${(e as Error).message}`, + ); + } + if (!res.ok) { + await res.body?.cancel(); + if (res.status === 429) throw new Error("Open Library is rate limiting, try again in a moment"); + if (res.status >= 500) throw new Error(`Open Library is having trouble (${res.status}), try again later`); + throw new Error(`Open Library answered ${res.status}`); + } + return await res.json() as T; +} + +export async function searchBooks( + query: string, + signal?: AbortSignal, + limit = 25, +): Promise<{ books: Book[]; total: number }> { + const isbn = isbnQuery(query); + const params = new URLSearchParams({ fields: SEARCH_FIELDS, limit: String(limit) }); + if (isbn) params.set("isbn", isbn); + else params.set("q", query.trim()); + const res = await getJson<{ numFound?: number; docs?: SearchDoc[] }>(`/search.json?${params}`, signal); + return { books: (res.docs ?? []).filter((d) => d.key && d.title).map(fromDoc), total: res.numFound ?? 0 }; +} + +const works = new Map(); + +export async function work(key: string, signal?: AbortSignal): Promise { + const hit = works.get(key); + if (hit) return hit; + const w = await getJson(`${key}.json`, signal); + works.set(key, w); + return w; +} + +// --------------------------------------------------------------------------- +// Reading list + +export const readingList = async () => await load("reading-list") ?? []; + +export async function setBookStatus(book: Book, status: Status | undefined): Promise { + await save("reading-list", setStatus(await readingList(), book, status)); +} diff --git a/extensions/books/backslash.toml b/extensions/books/backslash.toml new file mode 100644 index 0000000..fd49839 --- /dev/null +++ b/extensions/books/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "books" +title = "Books" +description = "Search Open Library for books with covers, authors, editions and subjects, copy ISBNs and citations, and keep a reading list" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# The Open Library API, and covers.openlibrary.org where it serves covers. +[permissions] +net = ["openlibrary.org", "covers.openlibrary.org"] + +[[commands]] +name = "search" +title = "Search Books" +description = "Find a book by title, author or ISBN" +icon = "bs-book" +keywords = [ + "book", + "books", + "open library", + "isbn", + "author", + "novel", + "citation", + "livre", + "livres", + "auteur", + "roman", + "bibliothèque", +] + +[[commands]] +name = "reading-list" +title = "Reading List" +description = "Books you want to read, are reading and have read" +icon = "bs-bookmark" +keywords = ["reading list", "books", "to read", "goodreads", "liste de lecture", "à lire", "lectures", "livres"] diff --git a/extensions/books/commands/reading-list.ts b/extensions/books/commands/reading-list.ts new file mode 100644 index 0000000..6291dd2 --- /dev/null +++ b/extensions/books/commands/reading-list.ts @@ -0,0 +1,39 @@ +import { type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { readingList } from "../api.ts"; +import { sortSaved, type Status, STATUSES } from "../lib.ts"; +import { bookItem } from "../views.ts"; + +let counts: Record = { want: 0, reading: 0, read: 0 }; + +export default (_ctx: Context) => + list({ + placeholder: "Filter your books by title, author or subject…", + filter: { + options: [ + { value: "all", title: "All" }, + ...(Object.keys(STATUSES) as Status[]).map((value) => ({ value, title: STATUSES[value].title })), + ], + }, + empty: { + title: "No books here", + description: "Add books from Search Books: Ctrl+S on a book picks Want to Read, Reading or Read", + icon: Icons.bookmark, + }, + status: () => [`${counts.reading} reading`, `${counts.want} to read`, `${counts.read} read`].join(" · "), + async items({ filter }) { + const saved = await readingList(); + counts = { want: 0, reading: 0, read: 0 }; + for (const s of saved) counts[s.status]++; + const current = sortSaved(saved).find((s) => s.status === "reading"); + updateCommandMetadata({ + subtitle: current ? `Reading ${current.book.title}` : counts.want ? `${counts.want} to read` : null, + }).catch(() => {}); + const shown = filter && filter in STATUSES ? saved.filter((s) => s.status === filter) : saved; + return sortSaved(shown).map((s) => + bookItem(s.book, { + status: s.status, + section: filter && filter in STATUSES ? undefined : STATUSES[s.status].title, + }) + ); + }, + }); diff --git a/extensions/books/commands/search.ts b/extensions/books/commands/search.ts new file mode 100644 index 0000000..6e94cde --- /dev/null +++ b/extensions/books/commands/search.ts @@ -0,0 +1,35 @@ +import { Icons, list } from "@backslash/api"; +import { readingList, searchBooks } from "../api.ts"; +import { sortSaved, STATUSES } from "../lib.ts"; +import { bookItem } from "../views.ts"; + +let total = 0; + +export default list({ + placeholder: "Search books by title, author or ISBN…", + empty: { + title: "Search Open Library", + description: "Type a title (dune), an author (le guin) or an ISBN (9780441172719)", + icon: Icons.book, + }, + status: (items) => + items.some((i) => !i.section) + ? `${items.length} of ${new Intl.NumberFormat("en-US").format(total)} book${ + total === 1 ? "" : "s" + } · Open Library` + : items.length + ? "Your reading list · type to search Open Library" + : "Open Library", + async search(query, { signal }) { + const saved = await readingList(); + if (!query.trim()) { + return sortSaved(saved).filter((s) => s.status !== "read").map((s) => + bookItem(s.book, { status: s.status, section: STATUSES[s.status].title }) + ); + } + const status = new Map(saved.map((s) => [s.book.key, s.status])); + const found = await searchBooks(query, signal); + total = found.total; + return found.books.map((b) => bookItem(b, { status: status.get(b.key) })); + }, +}); diff --git a/extensions/books/lib.ts b/extensions/books/lib.ts new file mode 100644 index 0000000..1f8d4d1 --- /dev/null +++ b/extensions/books/lib.ts @@ -0,0 +1,279 @@ +// Open Library answers, citations and the reading list. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** One document of `GET /search.json` (with the `fields` we ask for). */ +export interface SearchDoc { + key: string; + title: string; + subtitle?: string; + author_name?: string[]; + author_key?: string[]; + first_publish_year?: number; + edition_count?: number; + cover_i?: number; + isbn?: string[]; + subject?: string[]; + number_of_pages_median?: number; + ratings_average?: number; + ratings_count?: number; + publisher?: string[]; + language?: string[]; + want_to_read_count?: number; + already_read_count?: number; +} + +/** `GET /works/OL…W.json`, the fields we read. */ +export interface Work { + key: string; + title: string; + subtitle?: string; + description?: string | { type?: string; value: string }; + first_sentence?: string | { value: string }; + subjects?: string[]; + subject_places?: string[]; + subject_times?: string[]; + subject_people?: string[]; + first_publish_date?: string; + links?: { title: string; url: string }[]; + covers?: number[]; +} + +export interface Book { + /** `/works/OL45804W` */ + key: string; + title: string; + subtitle?: string; + authors: string[]; + authorKeys: string[]; + year?: number; + editions?: number; + cover?: number; + isbn?: string; + subjects: string[]; + pages?: number; + rating?: number; + ratings?: number; + publisher?: string; + languages: string[]; + wantToRead?: number; + alreadyRead?: number; +} + +export type Status = "want" | "reading" | "read"; + +export const STATUSES: Record = { + want: { title: "Want to Read", color: "blue", icon: "bs-bookmark" }, + reading: { title: "Reading", color: "orange", icon: "bs-book" }, + read: { title: "Read", color: "green", icon: "bs-circle-check" }, +}; + +export interface Saved { + book: Book; + status: Status; + added: number; + updated: number; +} + +// --------------------------------------------------------------------------- +// Mapping + +export const SEARCH_FIELDS = [ + "key", + "title", + "subtitle", + "author_name", + "author_key", + "first_publish_year", + "edition_count", + "cover_i", + "isbn", + "subject", + "number_of_pages_median", + "ratings_average", + "ratings_count", + "publisher", + "language", + "want_to_read_count", + "already_read_count", +].join(","); + +/** ISBN-13 first (the one shops and libraries use), then ISBN-10. */ +export function bestIsbn(isbns: string[] | undefined): string | undefined { + const clean = (isbns ?? []).map((i) => i.replace(/[^\dX]/gi, "").toUpperCase()); + return clean.find((i) => i.length === 13 && /^97[89]/.test(i)) ?? clean.find((i) => i.length === 10); +} + +/** Subjects worth showing: no "Fiction, general"-style catalog noise, no duplicates, short. */ +export function cleanSubjects(subjects: string[] | undefined, limit = 8): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of subjects ?? []) { + const s = raw.trim(); + const k = s.toLowerCase(); + if (!s || s.length > 32 || seen.has(k)) continue; + if (/\b(general|accessible book|protected daisy|in library|nyt:|award|lending library|large type)\b/i.test(s)) { + continue; + } + if (/[=:]/.test(s)) continue; + seen.add(k); + out.push(s); + if (out.length === limit) break; + } + return out; +} + +export function fromDoc(d: SearchDoc): Book { + return { + key: d.key, + title: d.title, + subtitle: d.subtitle, + authors: d.author_name ?? [], + authorKeys: d.author_key ?? [], + year: d.first_publish_year, + editions: d.edition_count, + cover: d.cover_i, + isbn: bestIsbn(d.isbn), + subjects: cleanSubjects(d.subject), + pages: d.number_of_pages_median, + rating: d.ratings_count ? d.ratings_average : undefined, + ratings: d.ratings_count, + publisher: d.publisher?.[0], + languages: d.language ?? [], + wantToRead: d.want_to_read_count, + alreadyRead: d.already_read_count, + }; +} + +export const coverUrl = (id: number | undefined, size: "S" | "M" | "L") => + id && id > 0 ? `https://covers.openlibrary.org/b/id/${id}-${size}.jpg` : undefined; + +export const workUrl = (key: string) => `https://openlibrary.org${key}`; +export const authorUrl = (key: string) => `https://openlibrary.org/authors/${key}`; + +/** Text of a work's description, without Open Library's trailing source notes and link refs. */ +export function description(value: Work["description"] | Work["first_sentence"]): string | undefined { + const text = typeof value === "string" ? value : value?.value; + if (!text) return undefined; + return text + .replace(/\r\n/g, "\n") + .split(/\n-{3,}\n/)[0] + .replace(/^\[\d+\]:\s*\S+\s*$/gm, "") + .replace(/\(\[source\]\[\d+\]\)/gi, "") + .replace(/\[([^\]]+)\]\[\d+\]/g, "$1") + .replace(/\n{3,}/g, "\n\n") + .trim() || undefined; +} + +/** A query that is an ISBN (with or without dashes) → the ISBN. */ +export function isbnQuery(query: string): string | undefined { + const digits = query.replace(/[\s-]/g, "").toUpperCase(); + return /^(97[89]\d{10}|\d{9}[\dX])$/.test(digits) ? digits : undefined; +} + +// --------------------------------------------------------------------------- +// Citations + +const PARTICLES = new Set([ + "de", + "da", + "di", + "du", + "van", + "von", + "der", + "den", + "le", + "la", + "del", + "della", + "st.", + "ter", +]); + +/** "J. R. R. Tolkien" → "Tolkien, J. R. R."; "Ursula K. Le Guin" → "Le Guin, U. K.". */ +export function apaName(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) return name.trim(); + let i = parts.length - 1; + while (i > 1 && PARTICLES.has(parts[i - 1].toLowerCase())) i--; + const last = parts.slice(i).join(" "); + const initials = parts.slice(0, i).map((p) => + p.split("-").map((x) => `${x.replace(/\./g, "").charAt(0).toUpperCase()}.`).join("-") + ); + return `${last}, ${initials.join(" ")}`; +} + +/** APA 7: `Herbert, F. (1965). Dune. Chilton Books.` */ +export function citation(b: Pick): string { + const names = b.authors.slice(0, 20).map(apaName); + const authors = names.length === 0 + ? "" + : names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`; + const title = b.subtitle ? `${b.title}: ${b.subtitle}` : b.title; + const parts = [ + authors ? `${authors}${authors.endsWith(".") ? "" : "."}` : "", + `(${b.year ?? "n.d."}).`, + `${title}${/[.?!]$/.test(title) ? "" : "."}`, + b.publisher ? `${b.publisher}.` : "", + ]; + return parts.filter(Boolean).join(" "); +} + +// --------------------------------------------------------------------------- +// Formatting + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** "Frank Herbert", "Terry Pratchett & Neil Gaiman", "A, B & 3 others". */ +export function byline(authors: string[]): string { + if (!authors.length) return "Unknown author"; + if (authors.length <= 2) return authors.join(" & "); + return `${authors[0]}, ${authors[1]} & ${authors.length - 2} other${authors.length === 3 ? "" : "s"}`; +} + +const LANGS: Record = { + eng: "English", + fre: "French", + ger: "German", + spa: "Spanish", + ita: "Italian", + por: "Portuguese", + rus: "Russian", + jpn: "Japanese", + chi: "Chinese", + dut: "Dutch", + pol: "Polish", + swe: "Swedish", + ara: "Arabic", + kor: "Korean", + tur: "Turkish", + gre: "Greek", + heb: "Hebrew", + cze: "Czech", + dan: "Danish", + fin: "Finnish", + nor: "Norwegian", + hun: "Hungarian", +}; + +export const languageNames = (codes: string[]) => codes.map((c) => LANGS[c] ?? c); + +/** Reading list order: reading first, then want to read, then read; recent first. */ +export function sortSaved(list: Saved[]): Saved[] { + const order: Record = { reading: 0, want: 1, read: 2 }; + return [...list].sort((a, b) => order[a.status] - order[b.status] || b.updated - a.updated); +} + +export function setStatus(list: Saved[], book: Book, status: Status | undefined, now = Date.now()): Saved[] { + const rest = list.filter((s) => s.book.key !== book.key); + if (!status) return rest; + const prev = list.find((s) => s.book.key === book.key); + return [{ book, status, added: prev?.added ?? now, updated: now }, ...rest]; +} diff --git a/extensions/books/lib_test.ts b/extensions/books/lib_test.ts new file mode 100644 index 0000000..e5cabd9 --- /dev/null +++ b/extensions/books/lib_test.ts @@ -0,0 +1,155 @@ +import { assertEquals } from "@std/assert"; +import { + apaName, + bestIsbn, + type Book, + byline, + citation, + cleanSubjects, + coverUrl, + description, + fromDoc, + isbnQuery, + languageNames, + type Saved, + type SearchDoc, + setStatus, + sortSaved, +} from "./lib.ts"; +import { bookItem } from "./views.ts"; + +// Shaped like `GET /search.json?q=dune&fields=…`. +const dune: SearchDoc = { + key: "/works/OL893415W", + title: "Dune", + author_name: ["Frank Herbert"], + author_key: ["OL79034A"], + first_publish_year: 1965, + edition_count: 120, + cover_i: 11481354, + isbn: ["0441172717", "9780441172719", "9780340960196"], + subject: ["Science Fiction", "Fiction, general", "Dune (Imaginary place)", "Science fiction", "nyt:paperback=2021"], + number_of_pages_median: 604, + ratings_average: 4.27, + ratings_count: 512, + publisher: ["Chilton Books", "Ace"], + language: ["eng", "fre"], + want_to_read_count: 2300, + already_read_count: 650, +}; + +Deno.test("search documents", () => { + const b = fromDoc(dune); + assertEquals(b.isbn, "9780441172719"); + assertEquals(b.subjects, ["Science Fiction", "Dune (Imaginary place)"]); + assertEquals(b.rating, 4.27); + assertEquals(b.publisher, "Chilton Books"); + const bare = fromDoc({ key: "/works/OL1W", title: "Untitled", ratings_average: 3, ratings_count: 0 }); + assertEquals(bare.authors, []); + assertEquals(bare.rating, undefined); + assertEquals(bare.isbn, undefined); +}); + +Deno.test("ISBNs", () => { + assertEquals(bestIsbn(["0441172717"]), "0441172717"); + assertEquals(bestIsbn(["978-0-441-17271-9", "0441172717"]), "9780441172719"); + assertEquals(bestIsbn(["080442957x"]), "080442957X"); + assertEquals(bestIsbn([]), undefined); + assertEquals(isbnQuery("978-0441172719"), "9780441172719"); + assertEquals(isbnQuery("0 441 17271 7"), "0441172717"); + assertEquals(isbnQuery("dune 1965"), undefined); + assertEquals(isbnQuery("1234567890123"), undefined); +}); + +Deno.test("descriptions", () => { + assertEquals( + description({ + type: "/type/text", + value: "Set on the desert planet Arrakis.\r\n\r\n----------\r\nContains:\r\n[1]: https://example.org", + }), + "Set on the desert planet Arrakis.", + ); + assertEquals( + description("A tale ([source][1])\n\nSee [the wiki][2].\n\n[1]: https://a.org\n[2]: https://b.org"), + "A tale \n\nSee the wiki.", + ); + assertEquals(description(undefined), undefined); + assertEquals(description({ value: " " }), undefined); +}); + +Deno.test("subjects", () => { + assertEquals( + cleanSubjects([ + "Fantasy", + "fantasy", + "Accessible book", + "Protected DAISY", + "Fiction, fantasy, general", + "Large type books", + "A subject that is much too long to show as a tag", + "Magic", + ]), + ["Fantasy", "Magic"], + ); +}); + +Deno.test("APA citations", () => { + assertEquals(apaName("Frank Herbert"), "Herbert, F."); + assertEquals(apaName("J. R. R. Tolkien"), "Tolkien, J. R. R."); + assertEquals(apaName("Ursula K. Le Guin"), "Le Guin, U. K."); + assertEquals(apaName("Jean-Paul Sartre"), "Sartre, J.-P."); + assertEquals(apaName("Ludwig van Beethoven"), "van Beethoven, L."); + assertEquals(apaName("Homer"), "Homer"); + assertEquals(citation(fromDoc(dune)), "Herbert, F. (1965). Dune. Chilton Books."); + assertEquals( + citation({ authors: ["Terry Pratchett", "Neil Gaiman"], year: 1990, title: "Good Omens", subtitle: "The Nice" }), + "Pratchett, T., & Gaiman, N. (1990). Good Omens: The Nice.", + ); + assertEquals( + citation({ authors: ["A B", "C D", "E F"], title: "What?", publisher: "Pub" }), + "B, A., D, C., & F, E. (n.d.). What? Pub.", + ); + assertEquals(citation({ authors: [], year: 2001, title: "Anonymous" }), "(2001). Anonymous."); +}); + +Deno.test("formatting", () => { + assertEquals(byline([]), "Unknown author"); + assertEquals(byline(["A", "B"]), "A & B"); + assertEquals(byline(["A", "B", "C"]), "A, B & 1 other"); + assertEquals(byline(["A", "B", "C", "D"]), "A, B & 2 others"); + assertEquals(languageNames(["eng", "fre", "xyz"]), ["English", "French", "xyz"]); + assertEquals(coverUrl(11481354, "L"), "https://covers.openlibrary.org/b/id/11481354-L.jpg"); + assertEquals(coverUrl(-1, "S"), undefined); + assertEquals(coverUrl(undefined, "S"), undefined); +}); + +Deno.test("reading list", () => { + const a: Book = fromDoc(dune); + const b: Book = fromDoc({ key: "/works/OL2W", title: "Hyperion" }); + const c: Book = fromDoc({ key: "/works/OL3W", title: "Solaris" }); + let list: Saved[] = []; + list = setStatus(list, a, "want", 1); + list = setStatus(list, b, "read", 2); + list = setStatus(list, c, "want", 3); + list = setStatus(list, a, "reading", 4); + assertEquals(list.find((s) => s.book.key === a.key)?.added, 1); + assertEquals(sortSaved(list).map((s) => s.book.title), ["Dune", "Solaris", "Hyperion"]); + assertEquals(setStatus(list, b, undefined).length, 2); +}); + +Deno.test("book item", () => { + const item = bookItem(fromDoc(dune), { status: "reading" }); + assertEquals(item.icon, "https://covers.openlibrary.org/b/id/11481354-S.jpg"); + assertEquals(item.detail?.image, "https://covers.openlibrary.org/b/id/11481354-L.jpg"); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), ["Reading", "1965"]); + assertEquals(item.detail?.stats?.map((s) => s.value), [1965, 120, "★ 4.3"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Show Description", + "Open on Open Library", + "Reading List: Reading", + ]); + const cite = item.actions?.find((a) => a.title === "Copy Citation (APA)"); + assertEquals(cite && "text" in cite ? cite.text : undefined, "Herbert, F. (1965). Dune. Chilton Books."); + const inSection = bookItem(fromDoc(dune), { status: "reading", section: "Reading" }); + assertEquals(inSection.accessories?.map((a) => a.text), ["1965"]); +}); diff --git a/extensions/books/views.ts b/extensions/books/views.ts new file mode 100644 index 0000000..24bbfcd --- /dev/null +++ b/extensions/books/views.ts @@ -0,0 +1,190 @@ +// Books as launcher items, the work page and the reading list actions. + +import { + type Accessory, + type Action, + action, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + type Outcome, + refresh, + replace, + type Stat, + submenu, + toast, +} from "@backslash/api"; +import { setBookStatus, work } from "./api.ts"; +import { + authorUrl, + type Book, + byline, + citation, + cleanSubjects, + compact, + coverUrl, + description, + languageNames, + type Status, + STATUSES, + workUrl, +} from "./lib.ts"; + +/** What to show after a status change: the list reloads, a book page is redrawn with the new status. */ +type After = (status: Status | undefined, ctx: Context) => Awaitable; +type Awaitable = T | Promise; + +const reload: After = () => refresh(); + +function statusActions(book: Book, current: Status | undefined, after: After): Action[] { + const set = (status: Status | undefined) => async (ctx: Context) => { + await setBookStatus(book, status); + await toast(status ? `${book.title}: ${STATUSES[status].title}` : `${book.title} removed from the reading list`, { + style: "success", + }); + return after(status, ctx); + }; + return [ + ...(Object.keys(STATUSES) as Status[]).filter((s) => s !== current).map((s) => + action(STATUSES[s].title, set(s), { icon: STATUSES[s].icon }) + ), + ...(current ? [action("Remove from Reading List", set(undefined), { icon: Icons.trash, destructive: true })] : []), + ]; +} + +function bookStats(b: Book): Stat[] { + const stats: Stat[] = []; + if (b.year) stats.push({ label: "First published", value: b.year }); + if (b.editions) stats.push({ label: "Editions", value: b.editions }); + if (b.rating !== undefined) stats.push({ label: "Rating", value: `★ ${b.rating.toFixed(1)}` }); + else if (b.pages) stats.push({ label: "Pages", value: b.pages }); + return stats; +} + +function bookMetadata(b: Book, status: Status | undefined): Metadata[] { + const lines: Metadata[] = []; + if (status) lines.push(meta.tags("Reading list", [{ text: STATUSES[status].title, color: STATUSES[status].color }])); + b.authors.slice(0, 4).forEach((name, i) => lines.push(meta.person(i ? "" : "By", name))); + if (b.subjects.length) lines.push(meta.tags("Subjects", b.subjects.slice(0, 6))); + if (b.pages) lines.push(meta.text("Pages", b.pages, { icon: Icons.file })); + if (b.publisher) lines.push(meta.text("Publisher", b.publisher)); + if (b.languages.length) lines.push(meta.text("Languages", languageNames(b.languages).slice(0, 5).join(", "))); + if (b.isbn) lines.push(meta.text("ISBN", b.isbn, { icon: Icons.hash })); + if (b.rating !== undefined && b.ratings) { + lines.push(meta.progress("Rating", b.rating / 5, `${b.rating.toFixed(2)} / 5 · ${compact(b.ratings)} ratings`)); + } + if (b.wantToRead || b.alreadyRead) { + lines.push( + meta.text( + "Readers", + [ + b.alreadyRead ? `${compact(b.alreadyRead)} read` : "", + b.wantToRead ? `${compact(b.wantToRead)} want to` : "", + ].filter(Boolean).join(" · "), + { icon: Icons.person }, + ), + ); + } + lines.push(meta.separator(), meta.link("Open Library", `openlibrary.org${b.key}`, workUrl(b.key))); + return lines; +} + +function bookActions(b: Book, status: Status | undefined, withDetails: boolean, after: After = reload): Action[] { + return [ + ...(withDetails + ? [action("Show Description", (ctx) => bookView(b, status, ctx.signal), { icon: Icons.book })] + : []), + open(workUrl(b.key), "Open on Open Library"), + submenu( + status ? `Reading List: ${STATUSES[status].title}` : "Add to Reading List", + statusActions(b, status, after), + { + icon: Icons.bookmark, + shortcut: "s", + }, + ), + ...(b.authorKeys[0] + ? [open(authorUrl(b.authorKeys[0]), `Open ${b.authors[0] ?? "Author"} on Open Library`, { + icon: Icons.person, + section: "Open", + })] + : []), + ...(b.isbn ? [copy(b.isbn, "Copy ISBN", { section: "Copy", shortcut: "i" })] : []), + copy(citation(b), "Copy Citation (APA)", { section: "Copy", shortcut: "c" }), + copy(workUrl(b.key), "Copy Open Library Link", { section: "Copy" }), + copy(`${b.title} by ${byline(b.authors)}`, "Copy Title and Author", { section: "Copy" }), + ]; +} + +export function bookItem(b: Book, o: { status?: Status; section?: string } = {}): Item { + const accessories: Accessory[] = []; + if (o.status && !o.section?.startsWith(STATUSES[o.status].title)) { + accessories.push({ + tag: STATUSES[o.status].title, + color: STATUSES[o.status].color, + tooltip: "In your reading list", + }); + } + if (b.year) accessories.push({ text: String(b.year), tooltip: "First published" }); + return { + id: `${o.section ?? ""}:${b.key}`, + title: b.title, + subtitle: byline(b.authors), + section: o.section, + icon: coverUrl(b.cover, "S") ?? { source: Icons.book, tint: "orange" }, + keywords: [...b.authors, ...(b.isbn ? [b.isbn] : []), ...b.subjects.slice(0, 4)], + accessories, + detail: { + image: coverUrl(b.cover, "L"), + stats: bookStats(b), + markdown: `**${b.title}**${b.subtitle ? `\n\n*${b.subtitle}*` : ""}`, + metadata: bookMetadata(b, o.status), + }, + actions: bookActions(b, o.status, true), + }; +} + +/** The work's description, subjects, places and times. */ +export async function bookView(b: Book, status: Status | undefined, signal: AbortSignal): Promise { + const w = await work(b.key, signal); + const text = description(w.description); + const first = description(w.first_sentence); + const md = [ + `# ${w.title || b.title}`, + ...(w.subtitle || b.subtitle ? [`*${w.subtitle || b.subtitle}*`] : []), + "", + `by **${byline(b.authors)}**${b.year ? ` · ${b.year}` : ""}`, + "", + text ?? (first ? `> ${first}` : "Open Library has no description of this book yet."), + ...(w.links?.length ? ["", "## Links", "", ...w.links.slice(0, 6).map((l) => `- [${l.title}](${l.url})`)] : []), + ]; + const metadata: Metadata[] = [...bookMetadata(b, status)]; + const subjects = cleanSubjects(w.subjects, 10); + const extra: Metadata[] = []; + if (subjects.length) extra.push(meta.tags("Subjects", subjects)); + const places = cleanSubjects(w.subject_places, 5); + if (places.length) extra.push(meta.tags("Places", places.map((text) => ({ text, color: "teal" as const })))); + const times = cleanSubjects(w.subject_times, 4); + if (times.length) extra.push(meta.tags("Times", times.map((text) => ({ text, color: "purple" as const })))); + const people = cleanSubjects(w.subject_people, 5); + if (people.length) extra.push(meta.tags("Characters", people.map((text) => ({ text, color: "pink" as const })))); + return detail({ + title: b.title, + markdown: md.join("\n"), + stats: bookStats(b), + metadata: [...metadata.filter((m) => m.label !== "Subjects"), meta.separator(), ...extra], + status: `${b.editions ? `${b.editions} edition${b.editions === 1 ? "" : "s"} · ` : ""}Open Library`, + actions: bookActions( + b, + status, + false, + async (next, ctx) => replace(await bookView(b, next, ctx.signal)), + ), + }); +} diff --git a/extensions/clickup/api.ts b/extensions/clickup/api.ts new file mode 100644 index 0000000..f782383 --- /dev/null +++ b/extensions/clickup/api.ts @@ -0,0 +1,128 @@ +// Calls to the ClickUp API v2 with the user's personal token. + +import type { Item } from "@backslash/api"; +import { + API, + AuthError, + clickupError, + type ListRef, + setupItem, + type Task, + type Team, + tokenOf, + type User, +} from "./lib.ts"; + +export async function clickup( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: token, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`ClickUp is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined); + if (!res.ok) throw clickupError(res.status, json); + return json as T; +} + +export interface Session { + token: string; + me: User; + teams: Team[]; +} + +let cached: Session | undefined; + +/** Who I am and my workspaces ("teams" in the API), asked once per process. */ +export async function session(prefs: Record, signal?: AbortSignal): Promise { + const token = tokenOf(prefs); + if (cached?.token === token) return cached; + const [{ user }, { teams }] = await Promise.all([ + clickup<{ user: User }>(token, "/user", { signal }), + clickup<{ teams: Team[] }>(token, "/team", { signal }), + ]); + cached = { token, me: user, teams }; + return cached; +} + +export async function guarded( + prefs: Record, + load: (s: Session) => Promise, + signal?: AbortSignal, +): Promise { + try { + return await load(await session(prefs, signal)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} + +/** My tasks in one workspace, page by page (100 each), up to 500. */ +export async function myTasks(s: Session, team: Team, closed: boolean, signal?: AbortSignal): Promise { + const out: Task[] = []; + for (let page = 0; page < 5; page++) { + const params = new URLSearchParams({ + page: String(page), + subtasks: "true", + include_closed: String(closed), + order_by: "due_date", + reverse: "true", + }); + params.append("assignees[]", String(s.me.id)); + const res = await clickup<{ tasks: Task[]; last_page?: boolean }>( + s.token, + `/team/${team.id}/task?${params}`, + { signal }, + ); + out.push(...res.tasks.map((t) => ({ ...t, team_id: t.team_id ?? team.id }))); + if (res.last_page === true || res.tasks.length < 100) break; + } + return out; +} + +/** Every list of every space and folder, for the new task form. */ +export async function allLists(s: Session): Promise<{ value: string; title: string }[]> { + const perTeam = await Promise.all(s.teams.map(async (team) => { + const { spaces } = await clickup<{ spaces: { id: string; name: string }[] }>( + s.token, + `/team/${team.id}/space?archived=false`, + ); + const perSpace = await Promise.all(spaces.map(async (space) => { + const [folders, loose] = await Promise.all([ + clickup<{ folders: { id: string; name: string; hidden?: boolean; lists: ListRef[] }[] }>( + s.token, + `/space/${space.id}/folder?archived=false`, + ).then((r) => r.folders).catch(() => []), + clickup<{ lists: ListRef[] }>(s.token, `/space/${space.id}/list?archived=false`) + .then((r) => r.lists).catch(() => []), + ]); + const prefix = s.teams.length > 1 ? `${team.name} › ${space.name}` : space.name; + return [ + ...loose.map((l) => ({ value: l.id, title: `${prefix} › ${l.name}` })), + ...folders.flatMap((f) => + f.lists.map((l) => ({ + value: l.id, + title: f.hidden ? `${prefix} › ${l.name}` : `${prefix} › ${f.name} › ${l.name}`, + })) + ), + ]; + })); + return perSpace.flat(); + })); + return perTeam.flat(); +} diff --git a/extensions/clickup/backslash.toml b/extensions/clickup/backslash.toml new file mode 100644 index 0000000..2bfbeb0 --- /dev/null +++ b/extensions/clickup/backslash.toml @@ -0,0 +1,32 @@ +[extension] +id = "clickup" +title = "ClickUp" +description = "Your ClickUp tasks across workspaces with their status, priority and due date; change status and create tasks" +version = "1.0.0" +author = "Backslash" +icon = "bs-circle-half" +license = "MIT" + +# The API, and people's pictures (attachments.clickup.com). +[permissions] +net = ["api.clickup.com", "attachments.clickup.com"] + +[[commands]] +name = "my-tasks" +title = "My ClickUp Tasks" +description = "Assigned to you in every workspace, by status; filter to do, in progress or done" +icon = "bs-circle-half" +keywords = ["clickup", "tasks", "my tasks", "todo", "status", "mes tâches", "tâches", "statut"] + +[[commands]] +name = "create-task" +title = "Create ClickUp Task" +description = "Name, list, priority and due date" +icon = "bs-plus" +keywords = ["clickup", "new", "task", "add", "create", "nouvelle tâche", "ajouter", "créer"] + +[[preferences]] +name = "token" +title = "Personal API token" +description = "ClickUp → avatar → Settings → Apps → API Token (pk_…)" +type = "secret" diff --git a/extensions/clickup/commands/create-task.ts b/extensions/clickup/commands/create-task.ts new file mode 100644 index 0000000..e2838d5 --- /dev/null +++ b/extensions/clickup/commands/create-task.ts @@ -0,0 +1,15 @@ +import { type Context, detail } from "@backslash/api"; +import { session } from "../api.ts"; +import { AuthError, setupItem } from "../lib.ts"; +import { taskForm } from "../views.ts"; + +// Text typed before opening the command becomes the task name. +export default async (ctx: Context) => { + try { + return await taskForm(await session(ctx.preferences), { name: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e); + return detail({ title: "Connect ClickUp", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/clickup/commands/my-tasks.ts b/extensions/clickup/commands/my-tasks.ts new file mode 100644 index 0000000..9044f0f --- /dev/null +++ b/extensions/clickup/commands/my-tasks.ts @@ -0,0 +1,58 @@ +import { action, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { guarded, myTasks, type Session, session } from "../api.ts"; +import { dueBucket, hasTime, matchesFilter, msDate, sortTasks, type Task } from "../lib.ts"; +import { live } from "../live.ts"; +import { sectionOrder, taskForm, taskItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter tasks by name, list, tag, ID…", + filter: { + options: [ + { value: "active", title: "Not Done" }, + { value: "todo", title: "To Do" }, + { value: "progress", title: "In Progress" }, + { value: "done", title: "Done" }, + ], + }, + empty: { title: "No task here", description: "Tasks assigned to you show up here.", icon: Icons.done }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async ({ preferences }) => taskForm(await session(preferences)), { + icon: Icons.plus, + shortcut: "n", + }), + ], + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (s: Session) => { + const closed = filter === "done"; + const all = (await Promise.all(s.teams.map((t) => myTasks(s, t, closed, signal)))).flat(); + const shown = all.filter((t) => matchesFilter(t, filter)); + const now = new Date(); + if (!filter || filter === "active") { + const overdue = shown.filter((t: Task) => { + const due = msDate(t.due_date); + return due && dueBucket(due, now, hasTime(due)) === "Overdue"; + }).length; + const moving = shown.filter((t) => t.status.type === "custom").length; + summary = [ + `${shown.length} task${shown.length === 1 ? "" : "s"}`, + moving ? `${moving} in progress` : "", + overdue ? `${overdue} overdue` : "", + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [moving ? `${moving} in progress` : "", overdue ? `${overdue} overdue` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + } else { + summary = `${shown.length} task${shown.length === 1 ? "" : "s"}`; + } + // Sections in status order; inside, by priority and due date. + const order = new Map(sectionOrder(shown).map((t, i) => [t.status.status, i])); + return sortTasks(shown) + .sort((a, b) => (order.get(a.status.status) ?? 0) - (order.get(b.status.status) ?? 0)) + .map((t) => taskItem(s, t, now)); + }, signal), signal), +}); diff --git a/extensions/clickup/lib.ts b/extensions/clickup/lib.ts new file mode 100644 index 0000000..f2624a6 --- /dev/null +++ b/extensions/clickup/lib.ts @@ -0,0 +1,435 @@ +// ClickUp: token, response types and pure mapping (statuses, priorities, dates). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.clickup.com/api/v2"; +export const TOKEN_URL = "https://app.clickup.com/settings/apps"; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your ClickUp account"); + if (!token.startsWith("pk_")) throw new AuthError("A ClickUp personal token starts with pk_"); + return token; +} + +/** `{ err, ECODE }` → an Error. */ +export function clickupError(status: number, body: unknown): Error { + const b = body as { err?: string; ECODE?: string } | undefined; + const message = b?.err ?? ""; + if (status === 401 || b?.ECODE === "OAUTH_017" || b?.ECODE === "OAUTH_019") { + return new AuthError("ClickUp rejected the API token"); + } + if (status === 429) return new Error("ClickUp is rate limiting: try again in a minute"); + if (status === 404) return new Error("Not found on ClickUp: it may have been deleted or moved"); + return new Error(`ClickUp answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Status { + status: string; + color: string; + type: string; + orderindex: number | string; +} + +export interface User { + id: number; + username: string | null; + email?: string; + initials?: string; + profilePicture?: string | null; +} + +export interface Task { + id: string; + custom_id?: string | null; + name: string; + text_content?: string | null; + status: Status; + date_created?: string; + date_updated?: string; + date_closed?: string | null; + creator?: User; + assignees: User[]; + tags: { name: string; tag_fg?: string; tag_bg?: string }[]; + parent?: string | null; + priority: { id: string; priority: string; color?: string } | null; + due_date: string | null; + start_date?: string | null; + time_estimate?: number | null; + list: { id: string; name: string }; + folder?: { id: string; name: string; hidden?: boolean }; + space?: { id: string }; + url: string; + team_id?: string; +} + +export interface Team { + id: string; + name: string; + color?: string; +} + +export interface ListRef { + id: string; + name: string; + statuses?: Status[]; +} + +// --------------------------------------------------------------------------- +// Statuses and priorities + +/** What's moving first: in progress (custom), to do (open), then done and closed. */ +const TYPE_ORDER = ["custom", "open", "done", "closed"]; + +export function typeRank(type: string): number { + const i = TYPE_ORDER.indexOf(type); + return i === -1 ? 1.5 : i; +} + +/** The palette color closest to a `#rrggbb` color. */ +export function colorOf(hex: string | null | undefined): Color { + // `#f50` is `#ff5500`. + const full = (hex ?? "").replace(/^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i, "#$1$1$2$2$3$3"); + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(full); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +export function statusIcon(status: Status): { source: string; tint: Color } { + const tint = colorOf(status.color); + switch (status.type) { + case "open": + return { source: Icons.todo, tint }; + case "done": + case "closed": + return { source: Icons.done, tint }; + default: + return { source: /review|qa|test/i.test(status.status) ? Icons.inReview : Icons.inProgress, tint }; + } +} + +/** "in progress" → "In Progress", as ClickUp shows them. */ +export function statusName(status: string): string { + return status.replace(/(^|[\s-])(\p{L})/gu, (_, sep: string, c: string) => sep + c.toUpperCase()); +} + +export const PRIORITIES = [ + { id: 1, name: "Urgent", icon: Icons.urgent, color: "red" as Color }, + { id: 2, name: "High", icon: Icons.high, color: "yellow" as Color }, + { id: 3, name: "Normal", icon: Icons.medium, color: "blue" as Color }, + { id: 4, name: "Low", icon: Icons.low, color: "gray" as Color }, +]; + +export function priorityOf(task: Pick) { + const id = Number(task.priority?.id ?? 0); + return PRIORITIES.find((p) => p.id === id); +} + +export function sortTasks(tasks: Task[]): Task[] { + const due = (t: Task) => t.due_date ? Number(t.due_date) : Infinity; + const prio = (t: Task) => priorityOf(t)?.id ?? 5; + return [...tasks].sort((a, b) => + typeRank(a.status.type) - typeRank(b.status.type) || + a.status.status.localeCompare(b.status.status) || + prio(a) - prio(b) || due(a) - due(b) + ); +} + +/** Which tasks a status filter keeps. */ +export function matchesFilter(task: Task, filter: string | undefined): boolean { + const type = task.status.type; + switch (filter) { + case "todo": + return type === "open"; + case "progress": + return type === "custom"; + case "done": + return type === "done" || type === "closed"; + default: + return type !== "done" && type !== "closed"; + } +} + +// --------------------------------------------------------------------------- +// Dates and people + +/** ClickUp dates are milliseconds as strings. */ +export const msDate = (ms: string | null | undefined) => ms ? new Date(Number(ms)) : undefined; + +/** + * ClickUp doesn't say whether a due date has a time: a day without one is + * stored at 4:00 (or midnight) local time. + */ +export function hasTime(d: Date): boolean { + return !((d.getHours() === 4 || d.getHours() === 0) && d.getMinutes() === 0); +} + +export function dueColor(task: Task, now = new Date()): Color | undefined { + const due = msDate(task.due_date); + if (!due || task.status.type === "done" || task.status.type === "closed") return undefined; + const bucket = dueBucket(due, now, hasTime(due)); + return bucket === "Overdue" ? "red" : bucket === "Today" ? "orange" : undefined; +} + +/** "2h 30m" from milliseconds. */ +export function estimate(ms: number | null | undefined): string | undefined { + if (!ms) return undefined; + const minutes = Math.round(ms / 60_000); + const h = Math.floor(minutes / 60), m = minutes % 60; + return [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m"; +} + +/** A picture only from the host in `net`, else the name for initials. */ +export function avatarOf(user: User): string { + const pic = user.profilePicture ?? ""; + if (pic.startsWith("https://attachments.clickup.com/")) return pic; + return user.username ?? user.email ?? "?"; +} + +export const whereOf = (task: Task) => + task.folder && !task.folder.hidden ? `${task.folder.name} › ${task.list.name}` : task.list.name; + +export function taskMetadata(task: Task, options: { team?: string } = {}, now = new Date()): Metadata[] { + const icon = statusIcon(task.status); + const prio = priorityOf(task); + const lines: Metadata[] = [ + meta.text("Status", statusName(task.status.status), { icon: icon.source, color: icon.tint }), + prio + ? meta.text("Priority", prio.name, { icon: prio.icon, color: prio.color }) + : meta.text("Priority", "None", { icon: Icons.noPriority }), + ]; + const due = msDate(task.due_date); + if (due) { + lines.push( + meta.text("Due", shortDue(due, now, hasTime(due)), { icon: Icons.calendar, color: dueColor(task, now) }), + ); + } + const start = msDate(task.start_date); + if (start) lines.push(meta.text("Start", shortDue(start, now, hasTime(start)), { icon: Icons.calendar })); + for (const [i, u] of task.assignees.entries()) { + lines.push(meta.person(i === 0 ? "Assignees" : "", u.username ?? u.email ?? "Someone", avatarOf(u))); + } + if (task.tags.length) { + lines.push(meta.tags("Tags", task.tags.map((t) => ({ text: t.name, color: colorOf(t.tag_bg) })))); + } + const est = estimate(task.time_estimate); + if (est) lines.push(meta.text("Estimate", est, { icon: Icons.timer })); + lines.push(meta.separator()); + lines.push(meta.text("List", whereOf(task), { icon: Icons.layers })); + if (options.team) lines.push(meta.text("Workspace", options.team, { icon: Icons.home })); + if (task.custom_id) lines.push(meta.text("ID", task.custom_id, { icon: Icons.hash })); + const updated = msDate(task.date_updated); + if (updated) lines.push(meta.text("Updated", shortDue(updated, now, false))); + lines.push(meta.link("Link", "Open in ClickUp", task.url)); + return lines; +} + +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +/** `due_date` (ms) and `due_date_time` for the API. */ +export function dueFields(when: When | undefined): { due_date: number | null; due_date_time?: boolean } { + if (!when) return { due_date: null }; + const d = new Date(when.date); + if (!when.time) d.setHours(4, 0, 0, 0); + return { due_date: d.getTime(), due_date_time: when.time }; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A personal API token from ClickUp", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect ClickUp", + "", + "1. In ClickUp, open your avatar → **Settings → Apps** (Enter opens it).", + "2. Under **API Token**, choose **Generate** (or **Copy** if you have one): it starts with `pk_`.", + "3. Paste it in **Settings → Extensions → ClickUp → Configure → Personal API token**.", + "", + "The token acts as you in every workspace you belong to; regenerating it in ClickUp revokes this one.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Get API Token", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/clickup/lib_test.ts b/extensions/clickup/lib_test.ts new file mode 100644 index 0000000..5403946 --- /dev/null +++ b/extensions/clickup/lib_test.ts @@ -0,0 +1,193 @@ +import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; +import { + AuthError, + avatarOf, + clickupError, + colorOf, + dueBucket, + dueChoices, + dueColor, + dueFields, + estimate, + hasTime, + matchesFilter, + parseWhen, + priorityOf, + shortDue, + sortTasks, + statusIcon, + statusName, + type Task, + taskMetadata, + tokenOf, + whereOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({}), AuthError, "Connect"); + assertThrows(() => tokenOf({ token: "abc" }), AuthError, "pk_"); + assertEquals(tokenOf({ token: " pk_123_ABC " }), "pk_123_ABC"); + assertInstanceOf(clickupError(401, { err: "Token invalid", ECODE: "OAUTH_025" }), AuthError); + assertInstanceOf(clickupError(400, { err: "Oauth token not found", ECODE: "OAUTH_019" }), AuthError); + assertEquals( + clickupError(400, { err: "Status does not exist", ECODE: "ITEM_120" }).message, + "ClickUp answered 400: Status does not exist", + ); +}); + +Deno.test("status colors, icons and names", () => { + assertEquals(colorOf("#d3d3d3"), "gray"); + assertEquals(colorOf("#4194f6"), "blue"); + assertEquals(colorOf("#6bc950"), "green"); + assertEquals(colorOf("#f50"), "orange"); + assertEquals(colorOf("#a875ff"), "purple"); + assertEquals(statusIcon({ status: "to do", color: "#d3d3d3", type: "open", orderindex: 0 }), { + source: "bs-circle", + tint: "gray", + }); + assertEquals( + statusIcon({ status: "in review", color: "#a875ff", type: "custom", orderindex: 2 }).source, + "bs-circle-three-quarters", + ); + assertEquals( + statusIcon({ status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 }).source, + "bs-circle-half", + ); + assertEquals( + statusIcon({ status: "complete", color: "#6bc950", type: "closed", orderindex: 3 }).source, + "bs-circle-check", + ); + assertEquals(statusName("in progress"), "In Progress"); + assertEquals(statusName("à faire"), "À Faire"); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const ms = (d: Date) => String(d.getTime()); + +function task(over: Partial): Task { + return { + id: "86abc", + name: "Task", + status: { status: "to do", color: "#d3d3d3", type: "open", orderindex: 0 }, + assignees: [], + tags: [], + priority: null, + due_date: null, + list: { id: "l1", name: "Sprint 12" }, + folder: { id: "f1", name: "Engineering", hidden: false }, + url: "https://app.clickup.com/t/86abc", + ...over, + }; +} + +Deno.test("filters by status type", () => { + const open = task({}); + const doing = task({ status: { status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 } }); + const done = task({ status: { status: "done", color: "#6bc950", type: "done", orderindex: 2 } }); + const closed = task({ status: { status: "closed", color: "#6bc950", type: "closed", orderindex: 3 } }); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "active")), [true, true, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "todo")), [true, false, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "progress")), [false, true, false, false]); + assertEquals([open, doing, done, closed].map((t) => matchesFilter(t, "done")), [false, false, true, true]); +}); + +Deno.test("priorities and order", () => { + const urgent = task({ id: "u", priority: { id: "1", priority: "urgent", color: "#f50000" } }); + const low = task({ id: "l", priority: { id: "4", priority: "low", color: "#d8d8d8" } }); + const none = task({ id: "n" }); + const doing = task({ id: "d", status: { status: "in progress", color: "#4194f6", type: "custom", orderindex: 1 } }); + assertEquals(priorityOf(urgent)?.name, "Urgent"); + assertEquals(priorityOf(none), undefined); + assertEquals(sortTasks([none, low, urgent, doing]).map((t) => t.id), ["d", "u", "l", "n"]); +}); + +Deno.test("dates: ms strings, day-only dues, estimates", () => { + assertEquals(hasTime(new Date(2026, 9, 28, 4, 0)), false); + assertEquals(hasTime(new Date(2026, 9, 28, 15, 30)), true); + const late = task({ due_date: ms(new Date(2026, 9, 26, 4, 0)) }); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(task({ due_date: ms(new Date(2026, 9, 27, 4, 0)) }), NOW), "orange"); + assertEquals(dueColor(task({ due_date: ms(new Date(2026, 10, 27)) }), NOW), undefined); + assertEquals(estimate(9_000_000), "2h 30m"); + assertEquals(estimate(null), undefined); + assertEquals(dueFields(undefined), { due_date: null }); + assertEquals(dueFields(parseWhen("friday", NOW)), { + due_date: new Date(2026, 9, 30, 4).getTime(), + due_date_time: false, + }); + assertEquals(dueFields(parseWhen("friday 3pm", NOW)), { + due_date: new Date(2026, 9, 30, 15).getTime(), + due_date_time: true, + }); +}); + +Deno.test("metadata, place and avatars", () => { + const t = task({ + custom_id: "ENG-42", + due_date: ms(new Date(2026, 9, 28, 17, 0)), + priority: { id: "2", priority: "high", color: "#ffcc00" }, + assignees: [ + { id: 1, username: "Ada", profilePicture: "https://attachments.clickup.com/profilePictures/1_abc.jpg" }, + { id: 2, username: "Grace", profilePicture: "https://elsewhere.example/x.jpg" }, + ], + tags: [{ name: "backend", tag_bg: "#4194f6" }], + time_estimate: 3_600_000, + }); + const lines = taskMetadata(t, { team: "Acme" }, NOW); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Status")?.value, "To Do"); + assertEquals(by("Priority")?.value, "High"); + assertEquals(by("Due")?.value, "tomorrow 17:00"); + assertEquals(by("Assignees")?.avatar, "https://attachments.clickup.com/profilePictures/1_abc.jpg"); + assertEquals(by("Tags")?.tags, [{ text: "backend", color: "blue" }]); + assertEquals(by("Estimate")?.value, "1h"); + assertEquals(by("ID")?.value, "ENG-42"); + assertEquals(by("Workspace")?.value, "Acme"); + assertEquals(avatarOf({ id: 2, username: "Grace", profilePicture: "https://elsewhere.example/x.jpg" }), "Grace"); + assertEquals(whereOf(t), "Engineering › Sprint 12"); + assertEquals(whereOf(task({ folder: { id: "f", name: "hidden", hidden: true } })), "Sprint 12"); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/clickup/live.ts b/extensions/clickup/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/clickup/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/clickup/views.ts b/extensions/clickup/views.ts new file mode 100644 index 0000000..23815c1 --- /dev/null +++ b/extensions/clickup/views.ts @@ -0,0 +1,204 @@ +// Items, actions and the new task form. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { allLists, clickup, type Session } from "./api.ts"; +import { + avatarOf, + dueChoices, + dueColor, + dueFields, + excerpt, + hasTime, + type ListRef, + msDate, + parseWhen, + PRIORITIES, + priorityOf, + shortDue, + type Status, + statusIcon, + statusName, + type Task, + taskMetadata, + typeRank, + whereOf, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +async function update(s: Session, task: Task, body: Record, message: string) { + await clickup(s.token, `/task/${task.id}`, { method: "PUT", body }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +/** The statuses of the task's list (lists can override their space's). */ +async function statusesOf(s: Session, task: Task): Promise { + const list = await clickup(s.token, `/list/${task.list.id}`); + let statuses = list.statuses ?? []; + if (!statuses.length && task.space) { + const space = await clickup<{ statuses?: Status[] }>(s.token, `/space/${task.space.id}`); + statuses = space.statuses ?? []; + } + return [...statuses].sort((a, b) => Number(a.orderindex) - Number(b.orderindex)); +} + +export function taskActions(s: Session, task: Task): Action[] { + const status = submenu("Change Status", async () => { + const statuses = await statusesOf(s, task); + return statuses.filter((st) => st.status !== task.status.status).map((st) => + action(statusName(st.status), () => update(s, task, { status: st.status }, statusName(st.status)), { + icon: statusIcon(st).source, + }) + ); + }, { icon: Icons.inProgress, shortcut: "s", section: "Task" }); + const priority = submenu( + "Set Priority", + [ + ...PRIORITIES.map((p) => + action(p.name, () => update(s, task, { priority: p.id }, `Priority ${p.name.toLowerCase()}`), { icon: p.icon }) + ), + action("No Priority", () => update(s, task, { priority: null }, "Priority removed"), { icon: Icons.noPriority }), + ], + { icon: Icons.high, shortcut: "p", section: "Task" }, + ); + const due = submenu( + "Change Due Date", + dueChoices().map((c) => + action(c.title, () => { + const when = c.date ? parseWhen(c.date) : undefined; + return update( + s, + task, + dueFields(when), + when ? `Due ${shortDue(when.date, new Date(), false)}` : "Due date removed", + ); + }, { icon: c.date ? Icons.calendar : Icons.cross }) + ), + { icon: Icons.calendar, shortcut: "d", section: "Task" }, + ); + return [ + open(task.url, "Open in ClickUp"), + status, + priority, + due, + action("New Task in This List…", () => taskForm(s, { list: task.list.id }), { + icon: Icons.plus, + shortcut: "n", + section: "Task", + }), + copy(task.url, "Copy Link", { shortcut: "c", section: "Copy" }), + copy(`[${task.name}](${task.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(task.custom_id ?? task.id, "Copy Task ID", { shortcut: "period", section: "Copy" }), + ]; +} + +export function taskItem(s: Session, task: Task, now = new Date()): Item { + const due = msDate(task.due_date); + const prio = priorityOf(task); + const team = s.teams.length > 1 ? s.teams.find((t) => t.id === task.team_id)?.name : undefined; + const others = task.assignees.filter((u) => u.id !== s.me.id); + return { + id: task.id, + title: task.name, + subtitle: whereOf(task), + icon: statusIcon(task.status), + section: statusName(task.status.status), + keywords: [ + task.custom_id ?? "", + task.list.name, + task.folder?.name ?? "", + ...task.tags.map((t) => t.name), + team ?? "", + ] + .filter(Boolean), + accessories: [ + ...task.tags.slice(0, 1).map((t) => ({ tag: t.name })), + ...(prio ? [{ icon: { source: prio.icon, tint: prio.color }, tooltip: `${prio.name} priority` }] : []), + ...(due + ? [{ icon: Icons.calendar, text: shortDue(due, now, hasTime(due)), color: dueColor(task, now), tooltip: "Due" }] + : []), + ...others.slice(0, 2).map((u) => ({ avatar: avatarOf(u), tooltip: u.username ?? undefined })), + ], + detail: { markdown: excerpt(task.text_content), metadata: taskMetadata(task, { team }, now) }, + actions: taskActions(s, task), + }; +} + +/** Statuses sorted like the sections: in progress, to do, done. */ +export function sectionOrder(tasks: Task[]): Task[] { + return [...tasks].sort((a, b) => + typeRank(a.status.type) - typeRank(b.status.type) || Number(a.status.orderindex) - Number(b.status.orderindex) + ); +} + +/** A new task; `standalone` when the form is the command itself (nothing to go back to). */ +export async function taskForm(s: Session, options: { list?: string; name?: string; standalone?: boolean } = {}) { + const lists = await allLists(s); + if (!lists.length) throw new Error("No ClickUp list found: create one in ClickUp first"); + const last = await storage.get("lastList").catch(() => undefined); + const initial = [options.list, last].find((id) => lists.some((l) => l.value === id)) ?? lists[0].value; + return form({ + title: "New ClickUp task", + submitTitle: "Create Task", + fields: [ + field.text("name", "Task name", { required: true, placeholder: "What needs doing?", value: options.name ?? "" }), + field.textArea("description", "Description"), + field.dropdown("list", "List", lists, { value: initial }), + field.dropdown( + "priority", + "Priority", + [{ value: "", title: "No priority" }, ...PRIORITIES.map((p) => ({ value: String(p.id), title: p.name }))], + { value: "" }, + ), + field.text("due", "Due", { placeholder: "today 5pm, friday, next week, +3d, 2026-11-03…" }), + field.checkbox("me", "Assign to me", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const task = await clickup(s.token, `/list/${values.list}/task`, { + method: "POST", + body: { + name: String(values.name).trim(), + description: String(values.description ?? "").trim() || undefined, + assignees: values.me ? [s.me.id] : [], + priority: values.priority ? Number(values.priority) : null, + ...(when ? dueFields(when) : {}), + }, + }); + await storage.set("lastList", String(values.list)).catch(() => {}); + notifyChanged(); + const where = lists.find((l) => l.value === values.list)?.title ?? ""; + await toast(`Created “${task.name}”`, { style: "success" }); + if (!options.standalone) return pop(); + return detail({ + title: task.name, + markdown: `# ${task.name}\n\nCreated in **${where}**.`, + metadata: [ + meta.text("List", where, { icon: Icons.layers }), + meta.text("Status", statusName(task.status?.status ?? "to do"), { + icon: task.status ? statusIcon(task.status).source : Icons.todo, + }), + ...(when ? [meta.text("Due", shortDue(when.date, new Date(), when.time), { icon: Icons.calendar })] : []), + meta.link("Link", "Open in ClickUp", task.url), + ], + status: "Task created", + actions: [open(task.url, "Open in ClickUp"), copy(task.url, "Copy Link", { shortcut: "c" })], + }); + }, + }); +} diff --git a/extensions/cloudflare/api.ts b/extensions/cloudflare/api.ts new file mode 100644 index 0000000..035000a --- /dev/null +++ b/extensions/cloudflare/api.ts @@ -0,0 +1,120 @@ +// Cloudflare API v4 with an API token, and the "connect" row shown without one. + +import { action, copy, Icons, type Item, open, refresh } from "@backslash/api"; +import { apiError, type Envelope, hasNextPage, mapZone, type Zone } from "./lib.ts"; + +export const API = "https://api.cloudflare.com/client/v4"; +export const TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens"; + +export class AuthError extends Error {} + +/** The token from the preference, else `$CLOUDFLARE_API_TOKEN`. */ +export function token(preferences: Record): string { + const t = String(preferences.token ?? "").trim() || Deno.env.get("CLOUDFLARE_API_TOKEN")?.trim() || ""; + if (!t) throw new AuthError("Connect your Cloudflare account"); + return t; +} + +export async function cf( + tok: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise> { + const res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${tok}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const body = await res.json().catch(() => undefined) as Envelope | undefined; + if (res.status === 401 || body?.errors?.some((e) => e.code === 10000 || e.code === 6003 || e.code === 9109)) { + throw new AuthError("Cloudflare rejected the API token"); + } + if (!res.ok || !body?.success) { + const msg = apiError(body, res.status); + if (res.status === 403) throw new Error(`${msg}. The token lacks a permission for this (see its scopes)`); + throw new Error(msg); + } + return body; +} + +/** Every page of a list endpoint (up to `max` items). */ +export async function cfAll( + tok: string, + path: string, + signal?: AbortSignal, + perPage = 50, + max = 2000, +): Promise { + const out: T[] = []; + for (let page = 1; out.length < max; page++) { + const sep = path.includes("?") ? "&" : "?"; + const res = await cf(tok, `${path}${sep}page=${page}&per_page=${perPage}`, { signal }); + out.push(...res.result); + if (!hasNextPage(res.result_info) || !res.result.length) break; + } + return out; +} + +export async function zones(tok: string, signal?: AbortSignal): Promise { + return (await cfAll(tok, "/zones?order=name", signal)).map(mapZone); +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "Create an API token, then paste it in the extension's preferences", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Cloudflare", + "", + "1. Open **My Profile → API Tokens** (Enter) and **Create Token** → **Create Custom Token** with:", + " - Zone → **Zone** → Read", + " - Zone → **DNS** → Edit", + " - Zone → **Cache Purge** → Purge", + " - Account → **Cloudflare Pages** → Read (Edit to retry deployments)", + " - Account → **Account Settings** → Read", + "2. Paste it in **Settings → Extensions → Cloudflare → Configure → API token**", + " (or export `CLOUDFLARE_API_TOKEN` before starting Backslash).", + ].join("\n"), + }, + actions: [ + open(TOKEN_URL, "Create API Token", { icon: Icons.key }), + copy("Zone:Read, DNS:Edit, Cache Purge:Purge, Pages:Read, Account Settings:Read", "Copy Needed Permissions", { + section: "Copy", + }), + ], + }; +} + +export function errorItem(err: unknown): Item { + const message = err instanceof Error ? err.message : String(err); + return { + id: "error", + title: /fetch|network|dns|connect/i.test(message) ? "Cannot reach Cloudflare" : "Cloudflare failed", + subtitle: message, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`\`\`\n${message}\n\`\`\`` }, + actions: [ + action("Retry", () => refresh(), { icon: Icons.refresh }), + open("https://www.cloudflarestatus.com", "Open Cloudflare Status", { icon: Icons.activity }), + copy(message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Turns a missing token or an API failure into one helpful row. */ +export async function guarded(load: () => Promise, signal?: AbortSignal): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + if (signal?.aborted) throw e; + return [errorItem(e)]; + } +} diff --git a/extensions/cloudflare/backslash.toml b/extensions/cloudflare/backslash.toml new file mode 100644 index 0000000..3d6c32d --- /dev/null +++ b/extensions/cloudflare/backslash.toml @@ -0,0 +1,39 @@ +[extension] +id = "cloudflare" +title = "Cloudflare" +description = "Manage Cloudflare zones and DNS records, purge the cache and follow Pages deployments" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +[permissions] +net = ["api.cloudflare.com"] +env = ["CLOUDFLARE_API_TOKEN"] + +[[commands]] +name = "zones" +title = "Cloudflare Zones" +description = "Your domains with their plan and status: DNS, cache purge, dashboard" +icon = "bs-globe" +keywords = ["cloudflare", "zone", "domain", "cdn", "cache", "purge", "domaine", "vider le cache"] + +[[commands]] +name = "dns" +title = "Cloudflare DNS Records" +description = "Records of all your zones: filter by type or proxy, copy, add, proxy, delete" +icon = "bs-globe" +keywords = ["cloudflare", "dns", "record", "a", "cname", "mx", "txt", "proxy", "enregistrement", "domaine"] + +[[commands]] +name = "pages" +title = "Cloudflare Pages" +description = "Pages projects with their latest deployment: open, deployments, retry" +icon = "bs-rocket" +keywords = ["cloudflare", "pages", "deploy", "deployment", "site", "static", "build", "déploiement"] + +[[preferences]] +name = "token" +title = "API token" +description = "dash.cloudflare.com → My Profile → API Tokens: Zone Read, DNS Edit, Cache Purge, Pages Read, Account Settings Read. Or set CLOUDFLARE_API_TOKEN" +type = "secret" diff --git a/extensions/cloudflare/commands/dns.ts b/extensions/cloudflare/commands/dns.ts new file mode 100644 index 0000000..3b40aef --- /dev/null +++ b/extensions/cloudflare/commands/dns.ts @@ -0,0 +1,6 @@ +import type { Context } from "@backslash/api"; +import { token } from "../api.ts"; +import { dnsView } from "../views.ts"; + +// Records of every zone the token sees, grouped by zone. +export default (ctx: Context) => dnsView(() => token(ctx.preferences)); diff --git a/extensions/cloudflare/commands/pages.ts b/extensions/cloudflare/commands/pages.ts new file mode 100644 index 0000000..0999ecf --- /dev/null +++ b/extensions/cloudflare/commands/pages.ts @@ -0,0 +1,166 @@ +import { + action, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { cf, cfAll, guarded, token } from "../api.ts"; +import { + age, + DEPLOY_COLORS, + DEPLOY_LABELS, + type Deployment, + deploymentDashboardUrl, + deployState, + formatDate, + mapDeployment, + mapProject, + pagesDashboardUrl, + type PagesProject, +} from "../lib.ts"; + +const STAGES: Record = { + queued: "Queued", + initialize: "Initializing", + clone_repo: "Cloning", + build: "Building", + deploy: "Deploying", +}; + +function deploymentMeta(d: Deployment) { + const state = deployState(d); + return [ + meta.text("State", `${DEPLOY_LABELS[state]}${state === "building" ? ` · ${STAGES[d.stage] ?? d.stage}` : ""}`, { + icon: Icons.dot, + color: DEPLOY_COLORS[state], + }), + meta.tags("Environment", [{ text: d.environment, color: d.environment === "production" ? "green" : "blue" }]), + ...(d.branch ? [meta.text("Branch", d.branch, { icon: Icons.branch })] : []), + ...(d.commit + ? [meta.text("Commit", `${d.commit}${d.message ? ` ${d.message}` : ""}`, { icon: Icons.commit })] + : []), + ...(d.url ? [meta.link("Preview", d.url.replace(/^https:\/\//, ""), d.url)] : []), + ...(d.trigger ? [meta.text("Trigger", d.trigger.replace(/_/g, " "), { icon: Icons.bolt })] : []), + ...(d.created ? [meta.text("Created", formatDate(d.created), { icon: Icons.calendar })] : []), + ]; +} + +function retry(tok: string, p: PagesProject, d: Deployment) { + return action("Retry Deployment", async () => { + await cf(tok, `/accounts/${p.accountId}/pages/projects/${p.name}/deployments/${d.id}/retry`, { method: "POST" }); + await toast(`Retrying ${p.name} ${d.shortId}`, { style: "success" }); + return refresh(); + }, { icon: Icons.refresh, shortcut: "r", section: "Deployment" }); +} + +function deploymentsView(tok: string, p: PagesProject): ListSpec { + return list({ + title: `${p.name} · deployments`, + placeholder: "Filter deployments by branch, commit, environment…", + empty: { title: "No deployments", icon: Icons.rocket }, + status: (items) => `${items.length} recent deployments of ${p.name}`, + items: ({ signal }) => + guarded(async () => { + const all = + (await cfAll(tok, `/accounts/${p.accountId}/pages/projects/${p.name}/deployments`, signal, 25, 50)) + .map(mapDeployment); + return all.map((d) => { + const state = deployState(d); + return { + id: d.id, + title: d.message ?? d.shortId, + subtitle: [d.branch, d.commit].filter(Boolean).join(" · "), + icon: { source: Icons.rocket, tint: DEPLOY_COLORS[state] }, + section: d.environment === "production" ? "Production" : "Preview", + keywords: [d.shortId, d.environment, d.branch ?? "", d.commit ?? ""], + accessories: [ + ...(d.created ? [{ text: age(d.created), tooltip: formatDate(d.created) }] : []), + { tag: DEPLOY_LABELS[state], color: DEPLOY_COLORS[state] }, + ], + detail: { metadata: deploymentMeta(d) }, + actions: [ + ...(d.url ? [open(d.url, "Open Deployment", { icon: Icons.globe })] : []), + open(deploymentDashboardUrl(p, d), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + ...(d.url ? [copy(d.url, "Copy Deployment URL", { shortcut: "c", section: "Copy" })] : []), + copy(d.id, "Copy Deployment ID", { section: "Copy" }), + ...(state === "failure" || state === "canceled" ? [retry(tok, p, d)] : []), + ], + } satisfies Item; + }); + }, signal), + }); +} + +function item(tok: string, p: PagesProject): Item { + const d = p.latest; + const state = deployState(d); + const color = DEPLOY_COLORS[state]; + const site = `https://${p.domains[0] ?? p.subdomain}`; + return { + id: `${p.accountId}/${p.name}`, + title: p.name, + subtitle: p.domains[0] ?? p.subdomain, + icon: { source: Icons.rocket, tint: color }, + section: state === "failure" ? "Failed" : state === "building" || state === "queued" ? "Building" : "Projects", + keywords: [p.subdomain, ...p.domains, p.repo ?? "", d?.branch ?? ""], + accessories: [ + ...(d?.branch ? [{ icon: Icons.branch, text: d.branch, tooltip: "Branch" }] : []), + ...(d?.created ? [{ text: age(d.created), tooltip: `Deployed ${formatDate(d.created)}` }] : []), + { tag: DEPLOY_LABELS[state], color }, + ], + detail: { + markdown: d?.message ? `**Latest:** ${d.message}` : undefined, + metadata: [ + ...(d ? deploymentMeta(d) : [meta.text("State", "No deployment yet", { icon: Icons.dot, color: "gray" })]), + meta.separator(), + meta.link("Site", site.replace(/^https:\/\//, ""), site), + ...(p.domains.length > 1 ? [meta.tags("Domains", p.domains)] : []), + ...(p.repo ? [meta.link("Repository", p.repo, `https://github.com/${p.repo}`)] : []), + ...(p.productionBranch ? [meta.text("Production branch", p.productionBranch, { icon: Icons.branch })] : []), + ], + }, + actions: [ + open(site, "Open Site", { icon: Icons.globe }), + action("Show Deployments", () => deploymentsView(tok, p), { icon: Icons.history, shortcut: "d" }), + open(pagesDashboardUrl(p), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + ...(d?.url ? [open(d.url, "Open Latest Deployment", { icon: Icons.rocket, section: "Open" })] : []), + copy(site, "Copy Site URL", { shortcut: "c", section: "Copy" }), + ...(d?.url ? [copy(d.url, "Copy Deployment URL", { section: "Copy" })] : []), + ...(d && (state === "failure" || state === "canceled") ? [retry(tok, p, d)] : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Pages projects…", + empty: { title: "No Pages projects", description: "This token sees no Cloudflare Pages project", icon: Icons.rocket }, + status: () => summary, + items: ({ preferences, signal }) => + guarded(async () => { + summary = ""; + const tok = token(preferences); + const accounts = await cfAll<{ id: string; name: string }>(tok, "/accounts", signal); + const projects = (await Promise.all(accounts.map(async (a) => + (await cfAll(tok, `/accounts/${a.id}/pages/projects`, signal, 10, 200)).map((p) => mapProject(p, a.id)) + ))).flat(); + const failed = projects.filter((p) => + deployState(p.latest) === "failure" + ).length; + summary = `${projects.length} project${projects.length === 1 ? "" : "s"}${failed ? ` · ${failed} failed` : ""}`; + await updateCommandMetadata({ subtitle: failed ? `${failed} failed` : null }).catch(() => {}); + const order = ["Failed", "Building", "Projects"]; + return projects.map((p) => + item(tok, p) + ).sort((a, b) => order.indexOf(a.section!) - order.indexOf(b.section!) || a.title.localeCompare(b.title)); + }, signal), +}); diff --git a/extensions/cloudflare/commands/zones.ts b/extensions/cloudflare/commands/zones.ts new file mode 100644 index 0000000..9242a83 --- /dev/null +++ b/extensions/cloudflare/commands/zones.ts @@ -0,0 +1,71 @@ +import { action, copy, Icons, type Item, list, meta, open, refresh, updateCommandMetadata } from "@backslash/api"; +import { guarded, token, zones } from "../api.ts"; +import { dnsDashboardUrl, formatDate, type Zone, zoneColor, zoneDashboardUrl, zoneStatusLabel } from "../lib.ts"; +import { dnsView, purgeActions, recordForm } from "../views.ts"; + +function item(z: Zone, tok: string, prefs: Record): Item { + const color = zoneColor(z); + const devMode = z.developmentMode > 0; + return { + id: z.id, + title: z.name, + subtitle: z.plan, + icon: { source: Icons.globe, tint: color }, + section: z.accountName || undefined, + keywords: [z.status, z.plan, z.accountName, ...z.nameServers], + accessories: [ + ...(devMode ? [{ tag: "Dev mode", color: "purple" as const }] : []), + ...(z.type !== "full" ? [{ text: z.type === "partial" ? "CNAME setup" : z.type, tooltip: "Setup" }] : []), + { tag: zoneStatusLabel(z), color }, + ], + detail: { + metadata: [ + meta.text("Status", zoneStatusLabel(z), { icon: Icons.dot, color }), + meta.tags("Plan", [{ text: z.plan || "Unknown", color: /free/i.test(z.plan) ? "gray" : "blue" }]), + ...(z.nameServers.length ? [meta.tags("Name servers", z.nameServers)] : []), + ...(z.originalRegistrar ? [meta.text("Registrar", z.originalRegistrar, { icon: Icons.home })] : []), + ...(devMode + ? [meta.text("Development mode", `${Math.round(z.developmentMode / 60)} min left`, { + icon: Icons.code, + color: "purple", + })] + : []), + meta.separator(), + meta.text("Account", z.accountName, { icon: Icons.person }), + meta.text("Zone ID", z.id, { icon: Icons.hash }), + ...(z.activated ? [meta.text("Active since", formatDate(z.activated), { icon: Icons.calendar })] : []), + ], + }, + actions: [ + action("Show DNS Records", () => dnsView(() => token(prefs), z), { icon: Icons.globe }), + open(zoneDashboardUrl(z), "Open in Dashboard", { icon: Icons.external, shortcut: "o" }), + action("Add DNS Record", () => recordForm(tok, z), { icon: Icons.plus, shortcut: "n" }), + ...purgeActions(tok, z), + open(dnsDashboardUrl(z), "Open DNS in Dashboard", { icon: Icons.globe, section: "Open" }), + copy(z.id, "Copy Zone ID", { shortcut: "period", section: "Copy" }), + copy(z.name, "Copy Domain", { section: "Copy" }), + ...(z.nameServers.length + ? [copy(z.nameServers.join("\n"), "Copy Name Servers", { icon: Icons.server, section: "Copy" })] + : []), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter zones by domain, plan, status…", + empty: { title: "No zones", description: "This token sees no zone: check its Zone resources", icon: Icons.globe }, + status: () => summary, + items: ({ preferences, signal }) => + guarded(async () => { + summary = ""; + const tok = token(preferences); + const all = await zones(tok, signal); + const pending = all.filter((z) => z.status === "pending").length; + summary = `${all.length} zone${all.length === 1 ? "" : "s"}${pending ? ` · ${pending} pending` : ""}`; + await updateCommandMetadata({ subtitle: summary }).catch(() => {}); + return all.map((z) => item(z, tok, preferences)); + }, signal), +}); diff --git a/extensions/cloudflare/lib.ts b/extensions/cloudflare/lib.ts new file mode 100644 index 0000000..1b48ae5 --- /dev/null +++ b/extensions/cloudflare/lib.ts @@ -0,0 +1,362 @@ +// Pure logic of the Cloudflare extension: API response mapping, DNS record +// validation and display, Pages states. No I/O here. + +export interface Envelope { + success: boolean; + errors?: { code: number; message: string }[]; + messages?: unknown[]; + result: T; + result_info?: { page: number; per_page: number; total_pages?: number; count: number; total_count?: number }; +} + +/** The first error of a failed response, readable. */ +export function apiError(body: Partial> | undefined, status: number): string { + const e = body?.errors?.[0]; + return e ? `${e.message}${e.code ? ` (${e.code})` : ""}` : `Cloudflare answered ${status}`; +} + +/** Whether there is a page after `info`. */ +export function hasNextPage(info: Envelope["result_info"]): boolean { + if (!info) return false; + if (info.total_pages !== undefined) return info.page < info.total_pages; + return info.count === info.per_page; +} + +// --- Zones ------------------------------------------------------------------------------- + +export interface Zone { + id: string; + name: string; + status: string; + paused: boolean; + type: string; + plan: string; + accountId: string; + accountName: string; + nameServers: string[]; + originalRegistrar?: string; + developmentMode: number; + activated?: Date; +} + +export function mapZone(z: any): Zone { + return { + id: z.id, + name: z.name, + status: z.status ?? "unknown", + paused: !!z.paused, + type: z.type ?? "full", + plan: z.plan?.name ?? "", + accountId: z.account?.id ?? "", + accountName: z.account?.name ?? "", + nameServers: z.name_servers ?? [], + originalRegistrar: z.original_registrar || undefined, + developmentMode: Number(z.development_mode ?? 0), + activated: z.activated_on ? new Date(z.activated_on) : undefined, + }; +} + +export const ZONE_COLORS: Record = { + active: "green", + pending: "yellow", + initializing: "yellow", + moved: "gray", + deactivated: "gray", + deleted: "red", + read_only: "gray", +}; + +export const zoneColor = (z: Zone) => z.paused ? "orange" as const : ZONE_COLORS[z.status] ?? "gray"; +export const zoneStatusLabel = (z: Zone) => z.paused ? "Paused" : capitalize(z.status.replace(/_/g, " ")); + +export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +export const zoneDashboardUrl = (z: Zone) => `https://dash.cloudflare.com/${z.accountId}/${z.name}`; +export const dnsDashboardUrl = (z: Zone) => `https://dash.cloudflare.com/${z.accountId}/${z.name}/dns/records`; + +// --- DNS records --------------------------------------------------------------------------- + +export interface DnsRecord { + id: string; + zoneId: string; + zoneName: string; + name: string; + type: string; + content: string; + proxied: boolean; + proxiable: boolean; + ttl: number; + priority?: number; + comment?: string; + tags: string[]; + modified?: Date; +} + +export function mapRecord(r: any, zone?: { id: string; name: string }): DnsRecord { + return { + id: r.id, + zoneId: r.zone_id ?? zone?.id ?? "", + zoneName: r.zone_name ?? zone?.name ?? "", + name: r.name, + type: r.type, + content: r.content ?? "", + proxied: !!r.proxied, + proxiable: !!r.proxiable, + ttl: Number(r.ttl ?? 1), + priority: typeof r.priority === "number" ? r.priority : undefined, + comment: r.comment || undefined, + tags: r.tags ?? [], + modified: r.modified_on ? new Date(r.modified_on) : undefined, + }; +} + +export const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "PTR", "HTTPS"]; + +/** Types Cloudflare can proxy. */ +export const PROXIABLE = ["A", "AAAA", "CNAME"]; + +export type RecordFilter = "all" | "address" | "cname" | "mx" | "txt" | "proxied" | "dns-only"; + +export function filterRecords(all: DnsRecord[], filter: RecordFilter): DnsRecord[] { + switch (filter) { + case "address": + return all.filter((r) => r.type === "A" || r.type === "AAAA"); + case "cname": + return all.filter((r) => r.type === "CNAME"); + case "mx": + return all.filter((r) => r.type === "MX"); + case "txt": + return all.filter((r) => r.type === "TXT"); + case "proxied": + return all.filter((r) => r.proxied); + case "dns-only": + return all.filter((r) => r.proxiable && !r.proxied); + default: + return all; + } +} + +const TYPE_ORDER = ["A", "AAAA", "CNAME", "MX", "TXT"]; + +export function sortRecords(all: DnsRecord[]): DnsRecord[] { + const rank = (t: string) => (TYPE_ORDER.indexOf(t) + 1) || 99; + return [...all].sort((a, b) => + a.zoneName.localeCompare(b.zoneName) || + // The apex first, then by name. + Number(b.name === b.zoneName) - Number(a.name === a.zoneName) || + a.name.localeCompare(b.name) || + rank(a.type) - rank(b.type) || + (a.priority ?? 0) - (b.priority ?? 0) + ); +} + +/** `www.example.com` in `example.com` → `www`; the apex → `@`. */ +export function shortName(name: string, zone: string): string { + if (name === zone) return "@"; + return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name; +} + +export const ttlLabel = (ttl: number) => + ttl === 1 + ? "Auto" + : ttl % 86400 === 0 + ? `${ttl / 86400} d` + : ttl % 3600 === 0 + ? `${ttl / 3600} h` + : ttl % 60 === 0 + ? `${ttl / 60} min` + : `${ttl} s`; + +/** A BIND zone file line. */ +export function zoneFileLine(r: DnsRecord): string { + const ttl = r.ttl === 1 ? 300 : r.ttl; + const content = r.type === "TXT" && !r.content.startsWith('"') ? `"${r.content.replace(/"/g, '\\"')}"` : r.content; + const fqdn = (s: string) => + /^[\w.-]+$/.test(s) && ["CNAME", "MX", "NS", "PTR"].includes(r.type) && !s.endsWith(".") ? `${s}.` : s; + return [`${r.name}.`, ttl, "IN", r.type, ...(r.priority !== undefined ? [r.priority] : []), fqdn(content)].join("\t"); +} + +export const TYPE_COLORS: Record = { + A: "blue", + AAAA: "purple", + CNAME: "teal", + MX: "orange", + TXT: "green", +}; + +export interface RecordInput { + type?: string; + name?: string; + content?: string; + ttl?: string; + priority?: string; + proxied?: boolean | string; + comment?: string; +} + +/** Validates the form and builds the API body. */ +export function recordBody(v: RecordInput, zone: string): Record { + const type = (v.type ?? "A").toUpperCase(); + if (!RECORD_TYPES.includes(type)) throw new Error(`Unsupported type ${type}`); + let name = (v.name ?? "").trim().replace(/\.$/, ""); + if (!name || name === "@") name = zone; + else if (name !== zone && !name.endsWith(`.${zone}`)) name = `${name}.${zone}`; + const content = (v.content ?? "").trim(); + if (!content) throw new Error("Content is required"); + if (type === "A" && !/^(\d{1,3}\.){3}\d{1,3}$/.test(content)) throw new Error(`“${content}” is not an IPv4 address`); + if (type === "AAAA" && !/^[0-9a-f:]+$/i.test(content)) throw new Error(`“${content}” is not an IPv6 address`); + const ttlText = (v.ttl ?? "").trim().toLowerCase(); + const ttl = !ttlText || ttlText === "auto" ? 1 : Number(ttlText); + if (!Number.isInteger(ttl) || (ttl !== 1 && (ttl < 60 || ttl > 86400))) { + throw new Error("TTL is “auto” or a number of seconds from 60 to 86400"); + } + const proxied = (v.proxied === true || v.proxied === "true") && PROXIABLE.includes(type); + const body: Record = { type, name, content, ttl: proxied ? 1 : ttl, proxied }; + if (type === "MX" || type === "SRV") { + const p = Number((v.priority ?? "").trim() || "10"); + if (!Number.isInteger(p) || p < 0 || p > 65535) throw new Error("Priority is a number from 0 to 65535"); + body.priority = p; + } + const comment = (v.comment ?? "").trim(); + if (comment) body.comment = comment; + return body; +} + +// --- Cache purge ------------------------------------------------------------------------- + +/** URLs from a text area: one per line or separated by spaces, scheme added when missing. */ +export function purgeUrls(text: string, zone: string): string[] { + const urls = text.split(/[\s,]+/).map((u) => u.trim()).filter(Boolean).map((u) => + /^https?:\/\//i.test(u) ? u : `https://${u.startsWith("/") ? zone + u : u}` + ); + for (const u of urls) { + let host: string; + try { + host = new URL(u).hostname; + } catch { + throw new Error(`“${u}” is not a URL`); + } + if (host !== zone && !host.endsWith(`.${zone}`)) throw new Error(`${host} is not in ${zone}`); + } + if (!urls.length) throw new Error("Enter at least one URL"); + return [...new Set(urls)]; +} + +/** Cloudflare accepts 30 URLs per purge request. */ +export function chunks(all: T[], size = 30): T[][] { + const out: T[][] = []; + for (let i = 0; i < all.length; i += size) out.push(all.slice(i, i + size)); + return out; +} + +// --- Pages ---------------------------------------------------------------------------------- + +export interface Deployment { + id: string; + shortId: string; + url: string; + environment: string; + stage: string; + status: string; + branch?: string; + commit?: string; + message?: string; + trigger?: string; + created?: Date; + aliases: string[]; +} + +export interface PagesProject { + name: string; + accountId: string; + subdomain: string; + domains: string[]; + productionBranch?: string; + repo?: string; + created?: Date; + latest?: Deployment; +} + +export function mapDeployment(d: any): Deployment { + const meta = d.deployment_trigger?.metadata ?? {}; + return { + id: d.id, + shortId: d.short_id ?? String(d.id).slice(0, 8), + url: d.url ?? "", + environment: d.environment ?? "production", + stage: d.latest_stage?.name ?? "", + status: d.latest_stage?.status ?? "", + branch: meta.branch || undefined, + commit: meta.commit_hash ? String(meta.commit_hash).slice(0, 7) : undefined, + message: meta.commit_message ? String(meta.commit_message).split("\n")[0] : undefined, + trigger: d.deployment_trigger?.type, + created: d.created_on ? new Date(d.created_on) : undefined, + aliases: d.aliases ?? [], + }; +} + +export function mapProject(p: any, accountId: string): PagesProject { + const src = p.source?.config; + return { + name: p.name, + accountId, + subdomain: p.subdomain ?? `${p.name}.pages.dev`, + domains: (p.domains ?? []).filter((d: string) => !d.endsWith(".pages.dev")), + productionBranch: p.production_branch || undefined, + repo: src?.owner && src?.repo_name ? `${src.owner}/${src.repo_name}` : undefined, + created: p.created_on ? new Date(p.created_on) : undefined, + latest: p.latest_deployment ? mapDeployment(p.latest_deployment) : undefined, + }; +} + +export type DeployState = "success" | "failure" | "building" | "queued" | "canceled" | "unknown"; + +/** One state from the latest stage: a finished deploy is a success, any failed stage a failure. */ +export function deployState(d: Deployment | undefined): DeployState { + if (!d) return "unknown"; + if (d.status === "failure") return "failure"; + if (d.status === "canceled") return "canceled"; + if (d.status === "success" && d.stage === "deploy") return "success"; + if (d.stage === "queued" || (d.status === "idle" && d.stage !== "deploy")) return "queued"; + if (d.status === "active" || d.status === "success" || d.status === "idle") return "building"; + return "unknown"; +} + +export const DEPLOY_COLORS = { + success: "green", + failure: "red", + building: "yellow", + queued: "blue", + canceled: "gray", + unknown: "gray", +} as const; + +export const DEPLOY_LABELS = { + success: "Deployed", + failure: "Failed", + building: "Building", + queued: "Queued", + canceled: "Canceled", + unknown: "No deployment", +}; + +export const pagesDashboardUrl = (p: { accountId: string; name: string }) => + `https://dash.cloudflare.com/${p.accountId}/pages/view/${p.name}`; + +export const deploymentDashboardUrl = (p: { accountId: string; name: string }, d: Deployment) => + `${pagesDashboardUrl(p)}/${d.id}`; + +// --- Formatting ------------------------------------------------------------------------------- + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = Math.max(0, (now.getTime() - date.getTime()) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; diff --git a/extensions/cloudflare/lib_test.ts b/extensions/cloudflare/lib_test.ts new file mode 100644 index 0000000..57f3218 --- /dev/null +++ b/extensions/cloudflare/lib_test.ts @@ -0,0 +1,204 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + apiError, + chunks, + deployState, + filterRecords, + hasNextPage, + mapDeployment, + mapProject, + mapRecord, + mapZone, + purgeUrls, + recordBody, + shortName, + sortRecords, + ttlLabel, + zoneColor, + zoneDashboardUrl, + zoneFileLine, + zoneStatusLabel, +} from "./lib.ts"; + +const ZONE = { + id: "023e105f4ecef8ad9ca31a8372d0c353", + name: "acme.io", + status: "active", + paused: false, + type: "full", + development_mode: 0, + name_servers: ["ada.ns.cloudflare.com", "bob.ns.cloudflare.com"], + original_registrar: "gandi sas", + account: { id: "a1b2", name: "Acme" }, + plan: { id: "x", name: "Pro Website" }, + activated_on: "2023-04-01T10:00:00Z", +}; + +Deno.test("zones", () => { + const z = mapZone(ZONE); + assertEquals(z.plan, "Pro Website"); + assertEquals(z.nameServers.length, 2); + assertEquals(zoneColor(z), "green"); + assertEquals(zoneStatusLabel(z), "Active"); + assertEquals(zoneDashboardUrl(z), "https://dash.cloudflare.com/a1b2/acme.io"); + const paused = mapZone({ ...ZONE, paused: true }); + assertEquals([zoneColor(paused), zoneStatusLabel(paused)], ["orange", "Paused"]); + assertEquals(zoneColor(mapZone({ ...ZONE, status: "pending" })), "yellow"); +}); + +Deno.test("pagination and errors", () => { + assertEquals(hasNextPage({ page: 1, per_page: 50, count: 50, total_pages: 2 }), true); + assertEquals(hasNextPage({ page: 2, per_page: 50, count: 3, total_pages: 2 }), false); + // Cursor-style endpoints give no total: a full page means there may be more. + assertEquals(hasNextPage({ page: 1, per_page: 10, count: 10 }), true); + assertEquals(hasNextPage(undefined), false); + assertEquals( + apiError({ errors: [{ code: 81057, message: "Record already exists." }] }, 400), + "Record already exists. (81057)", + ); + assertEquals(apiError(undefined, 502), "Cloudflare answered 502"); +}); + +const zone = { id: "z1", name: "acme.io" }; +const RECORDS = [ + { + id: "r3", + name: "acme.io", + type: "MX", + content: "mx2.mail.example", + priority: 20, + proxiable: false, + proxied: false, + ttl: 3600, + }, + { id: "r1", name: "acme.io", type: "A", content: "203.0.113.10", proxiable: true, proxied: true, ttl: 1 }, + { id: "r2", name: "www.acme.io", type: "CNAME", content: "acme.io", proxiable: true, proxied: true, ttl: 1 }, + { + id: "r5", + name: "acme.io", + type: "MX", + content: "mx1.mail.example", + priority: 10, + proxiable: false, + proxied: false, + ttl: 3600, + }, + { + id: "r4", + name: "acme.io", + type: "TXT", + content: "v=spf1 -all", + proxiable: false, + proxied: false, + ttl: 1, + comment: "SPF", + }, + { id: "r6", name: "ssh.acme.io", type: "A", content: "203.0.113.22", proxiable: true, proxied: false, ttl: 300 }, +].map((r) => mapRecord(r, zone)); + +Deno.test("DNS records: mapping, sort and filters", () => { + assertEquals(RECORDS[0].zoneName, "acme.io"); + assertEquals(RECORDS[4].comment, "SPF"); + assertEquals(sortRecords(RECORDS).map((r) => r.id), ["r1", "r5", "r3", "r4", "r6", "r2"]); + assertEquals(filterRecords(RECORDS, "address").map((r) => r.id), ["r1", "r6"]); + assertEquals(filterRecords(RECORDS, "proxied").map((r) => r.id), ["r1", "r2"]); + assertEquals(filterRecords(RECORDS, "dns-only").map((r) => r.id), ["r6"]); + assertEquals(filterRecords(RECORDS, "mx").length, 2); + assertEquals(shortName("www.acme.io", "acme.io"), "www"); + assertEquals(shortName("acme.io", "acme.io"), "@"); + assertEquals(shortName("other.dev", "acme.io"), "other.dev"); + assertEquals([ttlLabel(1), ttlLabel(300), ttlLabel(3600), ttlLabel(86400), ttlLabel(90)], [ + "Auto", + "5 min", + "1 h", + "1 d", + "90 s", + ]); +}); + +Deno.test("DNS records: zone file lines", () => { + const [mx, a, cname, , txt] = RECORDS; + assertEquals(zoneFileLine(mx), "acme.io.\t3600\tIN\tMX\t20\tmx2.mail.example."); + assertEquals(zoneFileLine(a), "acme.io.\t300\tIN\tA\t203.0.113.10"); + assertEquals(zoneFileLine(cname), "www.acme.io.\t300\tIN\tCNAME\tacme.io."); + assertEquals(zoneFileLine(txt), 'acme.io.\t300\tIN\tTXT\t"v=spf1 -all"'); +}); + +Deno.test("DNS records: form validation", () => { + assertEquals(recordBody({ type: "A", name: "api", content: "198.51.100.7", proxied: true }, "acme.io"), { + type: "A", + name: "api.acme.io", + content: "198.51.100.7", + ttl: 1, + proxied: true, + }); + assertEquals(recordBody({ type: "MX", name: "@", content: "mx.example", proxied: true, ttl: "3600" }, "acme.io"), { + type: "MX", + name: "acme.io", + content: "mx.example", + ttl: 3600, + proxied: false, + priority: 10, + }); + assertEquals( + recordBody({ type: "TXT", name: "_dmarc.acme.io.", content: "v=DMARC1; p=none", comment: " x " }, "acme.io"), + { + type: "TXT", + name: "_dmarc.acme.io", + content: "v=DMARC1; p=none", + ttl: 1, + proxied: false, + comment: "x", + }, + ); + assertThrows(() => recordBody({ type: "A", name: "x", content: "not-an-ip" }, "acme.io"), Error, "IPv4"); + assertThrows(() => recordBody({ type: "A", name: "x", content: "" }, "acme.io"), Error, "Content"); + assertThrows(() => recordBody({ type: "CNAME", name: "x", content: "y", ttl: "5" }, "acme.io"), Error, "TTL"); + assertThrows(() => recordBody({ type: "MX", name: "x", content: "y", priority: "-1" }, "acme.io"), Error, "Priority"); +}); + +Deno.test("cache purge URLs", () => { + assertEquals(purgeUrls("/index.html\nwww.acme.io/a.css https://acme.io/b.js /index.html", "acme.io"), [ + "https://acme.io/index.html", + "https://www.acme.io/a.css", + "https://acme.io/b.js", + ]); + assertThrows(() => purgeUrls("https://evil.com/x", "acme.io"), Error, "not in acme.io"); + assertThrows(() => purgeUrls(" ", "acme.io"), Error, "at least one"); + assertEquals(chunks([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); +}); + +const dep = (status: string, stage: string) => ({ + id: "4d1a6f0e-0000-4000-8000-1234567890ab", + short_id: "4d1a6f0e", + url: "https://4d1a6f0e.marketing.pages.dev", + environment: "production", + created_on: "2026-10-28T09:00:00Z", + latest_stage: { name: stage, status }, + deployment_trigger: { + type: "github:push", + metadata: { branch: "main", commit_hash: "abcdef1234567890", commit_message: "Update pricing\n\nbody" }, + }, +}); + +Deno.test("Pages projects and deployment states", () => { + const p = mapProject({ + name: "marketing", + subdomain: "marketing-7x.pages.dev", + domains: ["marketing-7x.pages.dev", "www.acme.io"], + production_branch: "main", + source: { type: "github", config: { owner: "acme", repo_name: "marketing" } }, + latest_deployment: dep("success", "deploy"), + }, "a1b2"); + assertEquals(p.domains, ["www.acme.io"]); + assertEquals(p.repo, "acme/marketing"); + assertEquals(p.latest?.commit, "abcdef1"); + assertEquals(p.latest?.message, "Update pricing"); + assertEquals(deployState(p.latest), "success"); + assertEquals(deployState(mapDeployment(dep("failure", "build"))), "failure"); + assertEquals(deployState(mapDeployment(dep("active", "build"))), "building"); + assertEquals(deployState(mapDeployment(dep("success", "build"))), "building"); + assertEquals(deployState(mapDeployment(dep("idle", "queued"))), "queued"); + assertEquals(deployState(mapDeployment(dep("canceled", "build"))), "canceled"); + assertEquals(deployState(undefined), "unknown"); +}); diff --git a/extensions/cloudflare/views.ts b/extensions/cloudflare/views.ts new file mode 100644 index 0000000..6395b58 --- /dev/null +++ b/extensions/cloudflare/views.ts @@ -0,0 +1,263 @@ +// DNS records (with their form), cache purge: shared by the zones and DNS commands. + +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + submenu, + toast, +} from "@backslash/api"; +import { cf, cfAll, guarded, zones as loadZones } from "./api.ts"; +import { + chunks, + dnsDashboardUrl, + type DnsRecord, + filterRecords, + formatDate, + mapRecord, + purgeUrls, + RECORD_TYPES, + recordBody, + type RecordFilter, + shortName, + sortRecords, + ttlLabel, + TYPE_COLORS, + type Zone, + zoneFileLine, +} from "./lib.ts"; + +// --- Live reload: forms and actions of this process notify the lists ---------------- + +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --- Cache purge --------------------------------------------------------------------- + +export function purgeActions(tok: string, z: Zone): Action[] { + return [ + submenu("Purge Cache", [ + action("Purge URLs…", () => purgeForm(tok, z), { icon: Icons.link }), + action("Purge Everything", async () => { + await cf(tok, `/zones/${z.id}/purge_cache`, { method: "POST", body: { purge_everything: true } }); + await toast(`Purged everything on ${z.name}`, { style: "success", message: "Edge caches refill on demand" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Purge every cached file of ${z.name}? Your origin gets the traffic until caches refill.`, + }), + ], { icon: Icons.bolt, shortcut: "p", section: "Cache" }), + ]; +} + +export function purgeForm(tok: string, z: Zone): FormSpec { + return form({ + title: `Purge URLs on ${z.name}`, + submitTitle: "Purge", + fields: [ + field.textArea("urls", "URLs", { + required: true, + placeholder: `https://${z.name}/styles.css\n/index.html`, + }), + ], + async submit(values) { + const urls = purgeUrls(String(values.urls ?? ""), z.name); + for (const files of chunks(urls)) { + await cf(tok, `/zones/${z.id}/purge_cache`, { method: "POST", body: { files } }); + } + await toast(`Purged ${urls.length} URL${urls.length === 1 ? "" : "s"}`, { style: "success" }); + return pop(); + }, + }); +} + +// --- DNS --------------------------------------------------------------------------------- + +export function recordForm(tok: string, z: Zone, type = "A"): FormSpec { + return form({ + title: `New DNS Record · ${z.name}`, + submitTitle: "Create Record", + fields: [ + field.dropdown("type", "Type", RECORD_TYPES, { value: type }), + field.text("name", "Name", { required: true, placeholder: `www, @ for ${z.name}, or a full name` }), + field.text("content", "Content", { + required: true, + placeholder: "IPv4 for A, IPv6 for AAAA, a hostname for CNAME/MX, text for TXT", + }), + field.checkbox("proxied", "Proxied through Cloudflare (A, AAAA, CNAME)", { value: true }), + field.text("ttl", "TTL", { placeholder: "auto, or seconds (60–86400)" }), + field.text("priority", "Priority", { placeholder: "MX and SRV only (default 10)" }), + field.text("comment", "Comment", { placeholder: "Optional note" }), + ], + async submit(values) { + const body = recordBody(values as Record, z.name); + await cf(tok, `/zones/${z.id}/dns_records`, { method: "POST", body }); + changed(); + await toast(`Created ${body.type} ${body.name}`, { style: "success" }); + return pop(); + }, + }); +} + +async function recordsOf(tok: string, z: Zone, signal?: AbortSignal): Promise { + return (await cfAll(tok, `/zones/${z.id}/dns_records`, signal, 100)).map((r) => mapRecord(r, z)); +} + +function recordItem(tok: string, r: DnsRecord, z: Zone, multiZone: boolean): Item { + const short = shortName(r.name, r.zoneName); + const actions: Action[] = [ + copy(r.content, "Copy Content"), + copy(r.name, "Copy Name", { shortcut: "period", section: "Copy" }), + copy(zoneFileLine(r), "Copy as Zone File Line", { icon: Icons.text, section: "Copy" }), + copy(r.id, "Copy Record ID", { icon: Icons.hash, section: "Copy" }), + ]; + if (r.proxiable) { + actions.push(action(r.proxied ? "Switch to DNS Only" : "Proxy Through Cloudflare", async () => { + await cf(tok, `/zones/${r.zoneId}/dns_records/${r.id}`, { method: "PATCH", body: { proxied: !r.proxied } }); + changed(); + await toast(`${r.name} is ${r.proxied ? "DNS only" : "proxied"}`, { style: "success" }); + }, { + icon: Icons.cloud, + shortcut: "x", + section: "Record", + confirm: r.proxied ? `Expose the origin of ${r.name}? DNS only answers with ${r.content}.` : undefined, + })); + } + actions.push( + action("Add Record", () => recordForm(tok, z, r.type), { + icon: Icons.plus, + shortcut: "n", + section: "Record", + }), + open(dnsDashboardUrl(z), "Open DNS in Dashboard", { icon: Icons.globe, shortcut: "o", section: "Record" }), + action("Delete Record", async () => { + await cf(tok, `/zones/${r.zoneId}/dns_records/${r.id}`, { method: "DELETE" }); + changed(); + await toast(`Deleted ${r.type} ${r.name}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${r.type} ${r.name} → ${r.content}?`, + shortcut: "Delete", + section: "Danger", + }), + ); + return { + id: r.id, + title: short, + subtitle: r.priority !== undefined ? `${r.priority} ${r.content}` : r.content, + icon: { + source: r.proxied ? Icons.cloud : Icons.globe, + tint: r.proxied ? "orange" : "gray", + }, + section: multiZone ? r.zoneName : undefined, + keywords: [r.name, r.type, r.content, r.comment ?? "", ...r.tags, r.proxied ? "proxied" : "dns only"], + accessories: [ + ...(r.comment ? [{ icon: Icons.comment, tooltip: r.comment }] : []), + ...(r.ttl !== 1 ? [{ text: ttlLabel(r.ttl), tooltip: "TTL" }] : []), + { tag: r.type, color: TYPE_COLORS[r.type] ?? "gray" }, + ], + detail: { + markdown: r.type === "TXT" || r.content.length > 60 ? `\`\`\`\n${r.content}\n\`\`\`` : undefined, + metadata: [ + meta.text("Name", r.name, { icon: Icons.globe }), + meta.tags("Type", [{ text: r.type, color: TYPE_COLORS[r.type] ?? "gray" }]), + ...(r.content.length <= 60 ? [meta.text("Content", r.content)] : []), + ...(r.priority !== undefined ? [meta.text("Priority", r.priority)] : []), + meta.text( + "Proxy", + r.proxied ? "Proxied" : r.proxiable ? "DNS only" : "DNS only (not proxiable)", + { icon: Icons.cloud, color: r.proxied ? "orange" : "gray" }, + ), + meta.text("TTL", ttlLabel(r.ttl), { icon: Icons.clock }), + ...(r.comment ? [meta.text("Comment", r.comment, { icon: Icons.comment })] : []), + ...(r.tags.length ? [meta.tags("Tags", r.tags)] : []), + meta.separator(), + meta.text("Zone", r.zoneName, { icon: Icons.layers }), + ...(r.modified ? [meta.text("Modified", formatDate(r.modified), { icon: Icons.calendar })] : []), + ], + }, + actions, + }; +} + +const FILTERS: { value: RecordFilter; title: string }[] = [ + { value: "all", title: "All" }, + { value: "address", title: "A / AAAA" }, + { value: "cname", title: "CNAME" }, + { value: "mx", title: "MX" }, + { value: "txt", title: "TXT" }, + { value: "proxied", title: "Proxied" }, + { value: "dns-only", title: "DNS Only" }, +]; + +/** + * DNS records of one zone, or of every zone when `zone` is undefined. + * Reloads by itself after a create, a proxy toggle or a delete. + */ +export function dnsView(tok: () => string, zone?: Zone): ListSpec { + let summary = ""; + let known: Zone[] = zone ? [zone] : []; + const addActions = (): Action[] => + zone ? [action("Add Record", () => recordForm(tok(), zone), { icon: Icons.plus, shortcut: "n" })] : [ + submenu( + "Add Record", + () => known.map((z) => action(z.name, () => recordForm(tok(), z), { icon: Icons.globe })), + { icon: Icons.plus, shortcut: "n" }, + ), + ]; + return list({ + title: zone ? `DNS · ${zone.name}` : undefined, + placeholder: zone ? `Filter ${zone.name} records by name, type, content…` : "Filter DNS records…", + filter: { options: FILTERS, default: "all" }, + empty: { + title: "No records", + description: "Nothing matches this filter. Press Ctrl+N to add a record.", + icon: Icons.globe, + }, + status: () => summary, + actions: addActions(), + items: ({ signal, filter }) => + live( + () => + guarded(async () => { + const t = tok(); + const zs = zone ? [zone] : (known = (await loadZones(t, signal)).filter((z) => z.status !== "deleted")); + const all = (await Promise.all(zs.map((z) => recordsOf(t, z, signal)))).flat(); + const shown = sortRecords(filterRecords(all, (filter ?? "all") as RecordFilter)); + const proxied = all.filter((r) => r.proxied).length; + summary = `${all.length} record${all.length === 1 ? "" : "s"} · ${proxied} proxied${ + zone ? ` · ${zone.name}` : ` · ${zs.length} zone${zs.length === 1 ? "" : "s"}` + }`; + const byId = new Map(zs.map((z) => [z.id, z])); + return shown.map((r) => recordItem(t, r, byId.get(r.zoneId)!, zs.length > 1)); + }, signal), + signal, + ), + }); +} diff --git a/extensions/codeberg/api.ts b/extensions/codeberg/api.ts new file mode 100644 index 0000000..508cc25 --- /dev/null +++ b/extensions/codeberg/api.ts @@ -0,0 +1,88 @@ +// The Forgejo API of codeberg.org (`/api/v1`) with a personal access token. + +import { Icons, type Item, open } from "@backslash/api"; + +export const HOST = "https://codeberg.org"; +export const TOKEN_URL = `${HOST}/user/settings/applications`; + +export class AuthError extends Error {} + +export function token(preferences: Record): string { + const t = String(preferences.token ?? "").trim(); + if (!t) throw new AuthError("Add a Codeberg access token"); + return t; +} + +export async function forgejo( + t: string, + path: string, + options: { method?: string; signal?: AbortSignal } = {}, +): Promise { + const res = await fetch(`${HOST}/api/v1${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { Authorization: `token ${t}`, Accept: "application/json" }, + }); + if (res.status === 401) throw new AuthError("Codeberg rejected the access token"); + if (res.status === 403) { + const body = await res.json().catch(() => ({})) as { message?: string }; + throw new AuthError( + /scope/i.test(body.message ?? "") + ? `The token lacks a permission: ${body.message}` + : `Codeberg refused the request${body.message ? `: ${body.message}` : ""}`, + ); + } + if (res.status === 429) throw new Error("Codeberg is rate limiting: try again in a minute"); + if (!res.ok) throw new Error(`Codeberg answered ${res.status}`); + if (res.status === 204 || res.status === 205) return undefined as T; + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** The row shown instead of results until a token works. */ +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Codeberg: personal access token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Codeberg", + "", + "1. Open **Settings → Applications** on codeberg.org (Enter opens it).", + "2. Under **Generate new token**, give it a name and these permissions:", + " - **issue**: Read (pull requests and issues)", + " - **repository**: Read", + " - **user**: Read", + " - **notification**: Read and write (to mark notifications as read)", + "3. Paste the token in **Settings → Extensions → Codeberg → Configure**.", + "", + "Only codeberg.org is supported: a self-hosted Forgejo or Gitea would need its own host in the extension's network permission.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Access Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise): 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, many = `${one}s`) => + items[0]?.id === "setup" ? "Not connected" : `${items.length} ${items.length === 1 ? one : many}`; diff --git a/extensions/codeberg/backslash.toml b/extensions/codeberg/backslash.toml new file mode 100644 index 0000000..c3d4723 --- /dev/null +++ b/extensions/codeberg/backslash.toml @@ -0,0 +1,47 @@ +[extension] +id = "codeberg" +title = "Codeberg" +description = "Your Codeberg pull requests with checks, issues, repositories and notifications" +version = "1.0.0" +author = "Backslash" +icon = "bs-repo" +license = "MIT" + +[permissions] +# codeberg.org only: the API and the avatars it serves. A self-hosted Forgejo or +# Gitea would need its own host here, which a preference can't add. +net = ["codeberg.org"] + +[[commands]] +name = "pull-requests" +title = "My Codeberg Pull Requests" +description = "Review requested, created by you, assigned or mentioning you: with branches, diff and checks" +icon = "bs-pr-open" +keywords = ["codeberg", "forgejo", "gitea", "pr", "pull request", "review", "merge request", "relecture"] + +[[commands]] +name = "issues" +title = "My Codeberg Issues" +description = "Open issues assigned to you, created by you or mentioning you" +icon = "bs-issue-open" +keywords = ["codeberg", "forgejo", "gitea", "issues", "bugs", "tickets", "problème"] + +[[commands]] +name = "repositories" +title = "Search Codeberg Repositories" +description = "Your repositories, or search all of Codeberg as you type" +icon = "bs-repo" +keywords = ["codeberg", "forgejo", "gitea", "repo", "git", "code", "dépôt"] + +[[commands]] +name = "notifications" +title = "Codeberg Notifications" +description = "Unread and pinned notifications; mark them read or pin them" +icon = "bs-bell" +keywords = ["codeberg", "forgejo", "gitea", "notifications", "inbox", "alerts"] + +[[preferences]] +name = "token" +title = "Access token" +description = "Create one in Codeberg → Settings → Applications with read access to issues, repositories and user, and read/write to notifications" +type = "secret" diff --git a/extensions/codeberg/commands/issues.ts b/extensions/codeberg/commands/issues.ts new file mode 100644 index 0000000..15e3276 --- /dev/null +++ b/extensions/codeberg/commands/issues.ts @@ -0,0 +1,29 @@ +import { list } from "@backslash/api"; +import { countStatus, forgejo, guarded, token } from "../api.ts"; +import { groupByRepo, type Issue, issueItem } from "../lib.ts"; + +const FILTERS: Record = { assigned: "assigned", created: "created", mentioned: "mentioned" }; + +export default list({ + placeholder: "Filter issues by title, repository, label, author…", + filter: { + options: [ + { value: "assigned", title: "Assigned to me" }, + { value: "created", title: "Created by me" }, + { value: "mentioned", title: "Mentioning me" }, + ], + }, + empty: { title: "No open issue", description: "Nothing matches this view.", icon: "bs-issue-open" }, + status: (items) => countStatus(items, "issue"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = token(preferences); + const key = filter && FILTERS[filter] ? filter : "assigned"; + const issues = await forgejo( + t, + `/repos/issues/search?type=issues&state=open&${FILTERS[key]}=true&limit=50`, + { signal }, + ); + return groupByRepo(issues).map((i) => issueItem(i, i.repository.full_name)); + }), +}); diff --git a/extensions/codeberg/commands/notifications.ts b/extensions/codeberg/commands/notifications.ts new file mode 100644 index 0000000..ea867fe --- /dev/null +++ b/extensions/codeberg/commands/notifications.ts @@ -0,0 +1,84 @@ +import { + action, + close, + copy, + Icons, + type Item, + list, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { countStatus, forgejo, guarded, token } from "../api.ts"; +import { ago, type Notification, notificationIcon, notificationMeta, notificationUrl, subjectNumber } from "../lib.ts"; + +function notificationItem(n: Notification, t: string): Item { + const number = subjectNumber(n); + const markRead = action("Mark as Read", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.check, shortcut: "r" }); + const markUnread = action("Mark as Unread", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=unread`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.dot, shortcut: "u" }); + const pin = action(n.pinned ? "Unpin" : "Pin", async () => { + await forgejo(t, `/notifications/threads/${n.id}?to-status=${n.pinned ? "read" : "pinned"}`, { method: "PATCH" }); + return refresh(); + }, { icon: Icons.pin, shortcut: "p" }); + return { + id: String(n.id), + title: n.subject.title, + subtitle: `${n.repository.full_name}${number ? ` #${number}` : ""}`, + icon: notificationIcon(n), + section: n.pinned ? "Pinned" : n.unread ? "Unread" : "Read", + keywords: [n.repository.full_name, n.subject.type], + accessories: [ + ...(n.unread ? [{ icon: { source: Icons.dot, tint: "blue" as const }, tooltip: "Unread" }] : []), + { text: ago(n.updated_at), tooltip: "Updated" }, + ], + detail: { metadata: notificationMeta(n) }, + actions: [ + // Opening a notification on the web marks it read there too. + action("Open in Browser", async () => { + await openExternal(notificationUrl(n)); + if (n.unread) { + await forgejo(t, `/notifications/threads/${n.id}?to-status=read`, { method: "PATCH" }).catch(() => {}); + } + return close(); + }, { icon: Icons.external }), + n.unread ? markRead : markUnread, + pin, + open(`https://codeberg.org/${n.repository.full_name}`, "Open Repository", { icon: Icons.repository }), + copy(notificationUrl(n), "Copy URL", { shortcut: "c", section: "Copy" }), + action("Mark All as Read", async () => { + await forgejo(t, "/notifications?to-status=read&status-types=unread", { method: "PUT" }); + await toast("All notifications marked as read", { style: "success" }); + return refresh(); + }, { icon: Icons.check, confirm: "Mark every notification as read?", section: "All" }), + ], + }; +} + +export default list({ + placeholder: "Filter notifications by title or repository…", + filter: { options: [{ value: "unread", title: "Unread" }, { value: "all", title: "All" }] }, + empty: { title: "You're all caught up", description: "No notification here.", icon: "bs-inbox" }, + status: (items) => countStatus(items, "notification"), + items: ({ preferences, signal, filter }) => + guarded(async () => { + const t = token(preferences); + const all = filter === "all"; + const types = all + ? "status-types=unread&status-types=pinned&status-types=read" + : "status-types=unread&status-types=pinned"; + const list = await forgejo(t, `/notifications?${types}&limit=50`, { signal }); + const unread = list.filter((n) => n.unread).length; + updateCommandMetadata({ subtitle: unread ? `${unread} unread` : null }).catch(() => {}); + const order = (n: Notification) => (n.pinned ? 0 : n.unread ? 1 : 2); + return [...list].sort((a, b) => order(a) - order(b) || b.updated_at.localeCompare(a.updated_at)) + .map((n) => notificationItem(n, t)); + }), +}); diff --git a/extensions/codeberg/commands/pull-requests.ts b/extensions/codeberg/commands/pull-requests.ts new file mode 100644 index 0000000..31bd902 --- /dev/null +++ b/extensions/codeberg/commands/pull-requests.ts @@ -0,0 +1,55 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { countStatus, forgejo, guardedSteps, token } from "../api.ts"; +import { type CombinedStatus, groupByRepo, type Issue, issueItem, type Pull, type PullDetail } from "../lib.ts"; + +const FILTERS: Record = { + review: "review_requested", + created: "created", + assigned: "assigned", + mentioned: "mentioned", +}; + +/** Branches, diff size and CI come from one call per pull request: only the first ones. */ +const ENRICH = 20; + +async function enrich(t: string, pr: Issue, signal: AbortSignal): Promise { + const base = `/repos/${pr.repository.full_name}`; + const detail = await forgejo(t, `${base}/pulls/${pr.number}`, { signal }).catch(() => undefined); + const ci = detail?.head.sha + ? await forgejo(t, `${base}/commits/${detail.head.sha}/status`, { signal }).catch(() => undefined) + : undefined; + return { ...pr, detail, ci }; +} + +export default list({ + placeholder: "Filter pull requests by title, repository, author, branch…", + filter: { + options: [ + { value: "review", title: "Review requested" }, + { value: "created", title: "Created by me" }, + { value: "assigned", title: "Assigned" }, + { value: "mentioned", title: "Mentioning me" }, + ], + }, + empty: { title: "Nothing here", description: "No open pull request matches.", icon: "bs-pr-open" }, + status: (items) => countStatus(items, "pull request"), + items: ({ preferences, signal, filter }) => + guardedSteps(async function* () { + const t = token(preferences); + const key = filter && FILTERS[filter] ? filter : "review"; + const found = await forgejo( + t, + `/repos/issues/search?type=pulls&state=open&${FILTERS[key]}=true&limit=50`, + { signal }, + ); + if (key === "review") { + updateCommandMetadata({ subtitle: found.length ? `${found.length} to review` : null }).catch(() => {}); + } + const prs = groupByRepo(found); + yield prs.map((pr) => issueItem(pr, pr.repository.full_name)); + const enriched = await Promise.all( + prs.map((pr, i) => (i < ENRICH ? enrich(t, pr, signal) : Promise.resolve(pr as Pull))), + ); + yield enriched.map((pr) => issueItem(pr, pr.repository.full_name)); + }), +}); diff --git a/extensions/codeberg/commands/repositories.ts b/extensions/codeberg/commands/repositories.ts new file mode 100644 index 0000000..844f43e --- /dev/null +++ b/extensions/codeberg/commands/repositories.ts @@ -0,0 +1,42 @@ +import { type Item, list, storage } from "@backslash/api"; +import { forgejo, guarded, token } from "../api.ts"; +import { type Repo, repoItem } from "../lib.ts"; + +let mine = 0; + +export default list({ + placeholder: "Search Codeberg repositories, or browse yours…", + empty: { title: "No repository found", icon: "bs-repo" }, + status: (items: Item[]) => + items[0]?.id === "setup" + ? "Not connected" + : items.every((i) => i.section === "Your Repositories") + ? `${mine} of your repositories` + : `${items.length} repositories`, + search: (query, { preferences, signal }) => + guarded(async () => { + const t = token(preferences); + const q = query.trim(); + if (!q) { + const repos = await forgejo(t, "/user/repos?limit=50", { signal }); + mine = repos.length; + await storage.set("mine", repos.map((r) => r.id)).catch(() => {}); + return [...repos].sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + .map((r) => repoItem(r, "Your Repositories")); + } + const [found, ids] = await Promise.all([ + forgejo<{ ok: boolean; data: Repo[] }>( + t, + `/repos/search?q=${encodeURIComponent(q)}&limit=30&sort=updated&order=desc`, + { + signal, + }, + ), + storage.get("mine").catch(() => undefined), + ]); + const own = new Set(ids ?? []); + // Yours first, then everyone's. + return [...found.data].sort((a, b) => Number(own.has(b.id)) - Number(own.has(a.id))) + .map((r) => repoItem(r, own.has(r.id) ? "Your Repositories" : "Codeberg")); + }), +}); diff --git a/extensions/codeberg/lib.ts b/extensions/codeberg/lib.ts new file mode 100644 index 0000000..ff1cffa --- /dev/null +++ b/extensions/codeberg/lib.ts @@ -0,0 +1,453 @@ +// Forgejo objects and how they look in lists and panels. Tested in lib_test.ts. + +import { + type Action, + action, + type Color, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, +} from "@backslash/api"; + +export interface User { + login: string; + full_name?: string; + avatar_url?: string; +} + +export interface Label { + name: string; + color: string; +} + +/** An issue or pull request from `/repos/issues/search`. */ +export interface Issue { + id: number; + number: number; + title: string; + html_url: string; + state: "open" | "closed"; + body?: string | null; + user: User; + labels?: Label[]; + milestone?: { title: string } | null; + assignees?: User[] | null; + comments: number; + created_at: string; + updated_at: string; + repository: { full_name: string; name: string; owner: string }; + pull_request?: { merged: boolean; draft?: boolean; html_url?: string } | null; +} + +/** `/repos/{owner}/{repo}/pulls/{index}`: what the search doesn't say. */ +export interface PullDetail { + head: { ref: string; sha: string }; + base: { ref: string }; + mergeable?: boolean; + merged?: boolean; + draft?: boolean; + additions?: number; + deletions?: number; + changed_files?: number; + requested_reviewers?: User[] | null; +} + +/** `/repos/{owner}/{repo}/commits/{sha}/status`. */ +export interface CombinedStatus { + state: "" | "pending" | "success" | "error" | "failure" | "warning"; + total_count: number; +} + +export interface Repo { + id: number; + full_name: string; + name: string; + owner: User; + description: string; + html_url: string; + clone_url: string; + ssh_url: string; + website?: string; + stars_count: number; + forks_count: number; + watchers_count?: number; + open_issues_count: number; + open_pr_counter?: number; + release_counter?: number; + language?: string; + default_branch: string; + private: boolean; + fork: boolean; + archived: boolean; + mirror?: boolean; + size?: number; + updated_at: string; + topics?: string[] | null; +} + +export interface Notification { + id: number; + unread: boolean; + pinned: boolean; + updated_at: string; + repository: { full_name: string }; + subject: { + title: string; + type: "Issue" | "Pull" | "Commit" | "Repository"; + state?: "open" | "closed" | "merged"; + html_url?: string; + latest_comment_html_url?: string; + }; +} + +// --- Formatting ------------------------------------------------------------------- + +/** "today", "yesterday", "3d", "25 Oct". */ +export function ago(iso: string, now = new Date()): string { + const d = new Date(iso); + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (days < 365) return d.toLocaleDateString("en", { day: "numeric", month: "short" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +export function compact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return String(n); +} + +/** The palette color closest to a label color (`e11d21` or `#e11d21`). */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").replace(//g, "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +// --- Issues and pull requests ----------------------------------------------------- + +export interface Pull extends Issue { + detail?: PullDetail; + ci?: CombinedStatus; +} + +export const isPull = (i: Issue) => !!i.pull_request; + +export function stateIcon(i: Pull): { source: string; tint: Color } { + if (isPull(i)) { + if (i.pull_request?.merged || i.detail?.merged) return { source: Icons.merged, tint: "purple" }; + if (i.state === "closed") return { source: Icons.pullRequestClosed, tint: "red" }; + if (i.pull_request?.draft || i.detail?.draft || /^(WIP|Draft)\b:?/i.test(i.title)) { + return { source: Icons.pullRequestDraft, tint: "gray" }; + } + return { source: Icons.pullRequest, tint: "green" }; + } + return i.state === "closed" ? { source: Icons.issueClosed, tint: "purple" } : { source: Icons.issue, tint: "green" }; +} + +function stateText(i: Pull): string { + const s = stateIcon(i).source; + if (s === Icons.merged) return "Merged"; + if (s === Icons.pullRequestDraft) return "Draft"; + return i.state === "closed" ? "Closed" : "Open"; +} + +/** CI of the head commit. */ +export function checks(i: Pull): { icon: string; color: Color; text: string } | undefined { + if (!i.ci || !i.ci.total_count) return undefined; + switch (i.ci.state) { + case "success": + return { icon: Icons.check, color: "green", text: "Checks passed" }; + case "failure": + case "error": + return { icon: Icons.cross, color: "red", text: "Checks failed" }; + case "warning": + return { icon: Icons.alert, color: "orange", text: "Checks passed with warnings" }; + case "pending": + return { icon: Icons.dot, color: "yellow", text: "Checks running" }; + default: + return undefined; + } +} + +export function issueMetadata(i: Pull): Metadata[] { + const s = stateIcon(i); + const ci = checks(i); + const lines: Metadata[] = [ + meta.text("Status", stateText(i), { icon: s.source, color: s.tint }), + meta.person("Author", i.user.full_name || i.user.login, i.user.avatar_url), + ]; + if (i.detail) lines.push(meta.text("Branch", `${i.detail.head.ref} → ${i.detail.base.ref}`, { icon: Icons.branch })); + if (ci) lines.push(meta.text("Checks", ci.text, { icon: ci.icon, color: ci.color })); + if (i.detail?.mergeable === false && i.state === "open" && !i.detail.merged) { + lines.push(meta.text("Merge", "Conflicts with the base branch", { icon: Icons.alert, color: "red" })); + } + const reviewers = (i.detail?.requested_reviewers ?? []).map((u) => u.login); + if (reviewers.length) lines.push(meta.tags("Reviewers", reviewers)); + const assignees = (i.assignees ?? []).map((u) => u.login); + if (assignees.length) lines.push(meta.tags("Assignees", assignees)); + if (i.labels?.length) { + lines.push(meta.tags("Labels", i.labels.map((l) => ({ text: l.name, color: colorOf(l.color) })))); + } + if (i.milestone) lines.push(meta.text("Milestone", i.milestone.title, { icon: Icons.flag })); + lines.push(meta.separator()); + lines.push(meta.text("Repository", i.repository.full_name, { icon: Icons.repository })); + lines.push(meta.text("Comments", String(i.comments), { icon: Icons.comment })); + lines.push(meta.text("Opened", ago(i.created_at))); + lines.push(meta.text("Updated", ago(i.updated_at))); + return lines; +} + +const stats = (i: Pull) => + i.detail?.additions !== undefined + ? [ + { label: "files", value: i.detail.changed_files ?? 0 }, + { label: "added", value: `+${i.detail.additions}`, color: "green" as Color }, + { label: "removed", value: `−${i.detail.deletions ?? 0}`, color: "red" as Color }, + ] + : []; + +export function issueActions(i: Pull, page = false): Action[] { + const ref = `${i.repository.full_name}#${i.number}`; + return [ + open(i.html_url, "Open in Browser"), + ...(page ? [] : [action("Show Details", () => issuePage(i), { icon: Icons.info })]), + ...(isPull(i) + ? [open(`${i.html_url}/files`, "Open Files Changed", { icon: Icons.code, shortcut: "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 https://codeberg.org/${i.repository.full_name}.git pull/${i.number}/head:pr-${i.number} && git switch pr-${i.number}`, + "Copy Checkout Command", + { icon: Icons.terminal, section: "Copy" }, + ), + ] + : []), + ]; +} + +export function issuePage(i: Pull): DetailSpec { + return detail({ + title: `#${i.number}`, + markdown: `# ${i.title}\n\n${i.body?.replace(//g, "").trim() || "_No description._"}`, + stats: stats(i), + metadata: issueMetadata(i), + status: `${i.repository.full_name} #${i.number}`, + actions: issueActions(i, true), + }); +} + +export function issueItem(i: Pull, section?: string): Item { + const ci = checks(i); + const labels = (i.labels ?? []).slice(0, 2); + return { + id: String(i.id), + title: i.title, + subtitle: section ? `#${i.number}` : `${i.repository.name} #${i.number}`, + icon: stateIcon(i), + section, + keywords: [ + i.repository.full_name, + String(i.number), + i.user.login, + ...(i.labels ?? []).map((l) => l.name), + i.detail?.head.ref ?? "", + ], + accessories: [ + ...(ci ? [{ icon: { source: ci.icon, tint: ci.color }, tooltip: ci.text }] : []), + ...labels.map((l) => ({ tag: l.name, color: colorOf(l.color) })), + ...(i.detail?.additions !== undefined + ? [{ + text: `+${i.detail.additions} −${i.detail.deletions ?? 0}`, + tooltip: `${i.detail.changed_files ?? 0} files`, + }] + : []), + ...(i.comments ? [{ icon: Icons.comment, text: String(i.comments), tooltip: "Comments" }] : []), + { avatar: i.user.avatar_url ?? i.user.login, tooltip: i.user.login }, + { text: ago(i.updated_at), tooltip: "Updated" }, + ], + detail: { stats: stats(i), markdown: excerpt(i.body), metadata: issueMetadata(i) }, + actions: issueActions(i), + }; +} + +/** Repositories first by most recent activity, as Codeberg orders them. */ +export function groupByRepo(issues: Pull[]): Pull[] { + const order = new Map(); + 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): string { + return n.subject.latest_comment_html_url || n.subject.html_url || `https://codeberg.org/${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/codeberg/lib_test.ts b/extensions/codeberg/lib_test.ts new file mode 100644 index 0000000..d1f6b43 --- /dev/null +++ b/extensions/codeberg/lib_test.ts @@ -0,0 +1,280 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + checks, + colorOf, + compact, + groupByRepo, + type Issue, + issueItem, + issueMetadata, + type Notification, + notificationIcon, + notificationUrl, + type Pull, + type Repo, + repoIcon, + repoItem, + stateIcon, + subjectNumber, +} from "./lib.ts"; +import { AuthError, setupItems, token } from "./api.ts"; + +// Realistic Forgejo 13 responses (shapes from the codeberg.org API docs). +const user = { id: 42, login: "ada", full_name: "Ada Lovelace", avatar_url: "https://codeberg.org/avatars/1f2e" }; +const repo = { id: 7, name: "backslash", owner: "backslash-app", full_name: "backslash-app/backslash" }; +const issues = [ + { + id: 901, + number: 57, + title: "Detect terminals before pasting", + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + state: "open", + body: "Fixes pasting into Kitty.\n\n", + user, + labels: [{ id: 1, name: "bug", color: "ee0701" }], + milestone: { title: "1.2" }, + assignees: [user], + comments: 3, + created_at: "2026-10-26T08:00:00Z", + updated_at: "2026-10-28T09:00:00Z", + repository: repo, + pull_request: { + merged: false, + merged_at: null, + draft: false, + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + }, + }, + { + id: 902, + number: 12, + title: "WIP: Forgejo Actions cache", + html_url: "https://codeberg.org/ada/notes/pulls/12", + state: "open", + body: "", + user, + labels: [], + milestone: null, + assignees: null, + comments: 0, + created_at: "2026-10-20T08:00:00Z", + updated_at: "2026-10-27T09:00:00Z", + repository: { id: 8, name: "notes", owner: "ada", full_name: "ada/notes" }, + pull_request: { merged: false, draft: true }, + }, +]; +const pull57 = { + id: 5001, + number: 57, + head: { ref: "fix/kitty-paste", sha: "3f2a9c1d" }, + base: { ref: "main" }, + mergeable: true, + merged: false, + draft: false, + additions: 86, + deletions: 12, + changed_files: 4, + requested_reviewers: [{ login: "grace" }], +}; +const status = { state: "failure", total_count: 2, statuses: [] }; +const repos = [ + { + id: 7, + name: "backslash", + full_name: "backslash-app/backslash", + owner: user, + description: "A launcher for Linux", + html_url: "https://codeberg.org/backslash-app/backslash", + clone_url: "https://codeberg.org/backslash-app/backslash.git", + ssh_url: "git@codeberg.org:backslash-app/backslash.git", + website: "https://backslash.dev", + stars_count: 1234, + forks_count: 56, + open_issues_count: 12, + open_pr_counter: 3, + release_counter: 5, + language: "Rust", + default_branch: "main", + private: false, + fork: false, + archived: false, + mirror: false, + updated_at: "2026-10-28T08:00:00Z", + topics: ["launcher", "gtk"], + }, + { + id: 8, + name: "notes", + full_name: "ada/notes", + owner: user, + description: "", + html_url: "https://codeberg.org/ada/notes", + clone_url: "https://codeberg.org/ada/notes.git", + ssh_url: "git@codeberg.org:ada/notes.git", + stars_count: 0, + forks_count: 0, + open_issues_count: 0, + language: "", + default_branch: "main", + private: true, + fork: false, + archived: false, + updated_at: "2026-10-20T08:00:00Z", + }, +]; +const notifications = [ + { + id: 300, + unread: true, + pinned: false, + updated_at: "2026-10-28T07:00:00Z", + repository: repo, + subject: { + title: "Detect terminals before pasting", + url: "https://codeberg.org/api/v1/repos/backslash-app/backslash/pulls/57", + html_url: "https://codeberg.org/backslash-app/backslash/pulls/57", + latest_comment_html_url: "https://codeberg.org/backslash-app/backslash/pulls/57#issuecomment-99", + type: "Pull", + state: "open", + }, + url: "https://codeberg.org/api/v1/notifications/threads/300", + }, + { + id: 301, + unread: false, + pinned: true, + updated_at: "2026-10-25T07:00:00Z", + repository: repo, + subject: { + title: "Crash on Wayland", + html_url: "https://codeberg.org/backslash-app/backslash/issues/40", + type: "Issue", + state: "closed", + }, + }, +]; + +const pr = issues[0] as unknown as Issue; +const draft = issues[1] as unknown as Issue; + +Deno.test("pull request state, checks and metadata", () => { + assertEquals(stateIcon(pr), { source: "bs-pr-open", tint: "green" }); + assertEquals(stateIcon(draft), { source: "bs-pr-draft", tint: "gray" }); + assertEquals(stateIcon({ ...pr, pull_request: { merged: true } }), { source: "bs-pr-merged", tint: "purple" }); + assertEquals(stateIcon({ ...pr, state: "closed" }), { source: "bs-pr-closed", tint: "red" }); + assertEquals(stateIcon({ ...pr, pull_request: null }), { source: "bs-issue-open", tint: "green" }); + const full: Pull = { ...pr, detail: pull57, ci: status as Pull["ci"] }; + assertEquals(checks(full), { icon: "bs-x", color: "red", text: "Checks failed" }); + assertEquals(checks({ ...full, ci: { state: "success", total_count: 1 } })?.text, "Checks passed"); + assertEquals(checks({ ...full, ci: { state: "pending", total_count: 0 } }), undefined); + const lines = issueMetadata(full).map((m) => [m.label, m.value ?? m.tags?.map((t) => t.text).join(",")]); + assertEquals(lines.slice(0, 8), [ + ["Status", "Open"], + ["Author", "Ada Lovelace"], + ["Branch", "fix/kitty-paste → main"], + ["Checks", "Checks failed"], + ["Reviewers", "grace"], + ["Assignees", "ada"], + ["Labels", "bug"], + ["Milestone", "1.2"], + ]); + const conflict = issueMetadata({ ...full, detail: { ...pull57, mergeable: false } }); + assertEquals(conflict.some((m) => m.value === "Conflicts with the base branch"), true); +}); + +Deno.test("pull request items", () => { + const item = issueItem({ ...pr, detail: pull57, ci: status as Pull["ci"] }, "backslash-app/backslash"); + assertEquals([item.title, item.subtitle, item.section], [ + "Detect terminals before pasting", + "#57", + "backslash-app/backslash", + ]); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text ?? a.tooltip), [ + "Checks failed", + "bug", + "+86 −12", + "3", + "ada", + item.accessories![5].text, + ]); + assertEquals(item.detail?.stats?.map((s) => s.value), [4, "+86", "−12"]); + assertEquals(item.detail?.markdown, "Fixes pasting into Kitty."); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Open in Browser", + "Show Details", + "Open Files Changed", + ]); + const checkout = item.actions?.find((a) => a.title === "Copy Checkout Command") as { text: string }; + assertEquals( + checkout.text, + "git fetch https://codeberg.org/backslash-app/backslash.git pull/57/head:pr-57 && git switch pr-57", + ); + // Before the details load: no branch, no diff. + assertEquals(issueItem(pr).subtitle, "backslash #57"); + assertEquals(issueItem(pr).actions?.some((a) => a.title === "Copy Branch Name"), false); +}); + +Deno.test("grouped by repository, most recent first", () => { + const a = { ...pr, id: 1, repository: { ...pr.repository, full_name: "x/a" } }; + const b = { ...pr, id: 2, repository: { ...pr.repository, full_name: "x/b" } }; + const a2 = { ...a, id: 3 }; + assertEquals(groupByRepo([a, b, a2]).map((i) => i.id), [1, 3, 2]); +}); + +Deno.test("repositories", () => { + const [main, notes] = repos as unknown as Repo[]; + assertEquals(repoIcon(main), { source: "bs-repo", tint: "orange" }); + assertEquals(repoIcon(notes), { source: "bs-lock", tint: "orange" }); + assertEquals(repoIcon({ ...main, archived: true }).source, "bs-archive"); + const item = repoItem(main, "Your Repositories"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["1.2k", "56", 12, 3]); + assertEquals(item.accessories?.[0].text, "Rust"); + assertEquals( + (item.actions?.find((a) => a.title === "Copy git clone (SSH)") as { text: string }).text, + "git clone git@codeberg.org:backslash-app/backslash.git", + ); + assertEquals(repoItem(notes).detail?.metadata?.find((m) => m.label === "Visibility")?.tags, [{ + text: "Private", + color: "orange", + }]); +}); + +Deno.test("notifications", () => { + const [unread, pinned] = notifications as unknown as Notification[]; + assertEquals(notificationIcon(unread), { source: "bs-pr-open", tint: "green" }); + assertEquals(notificationIcon(pinned), { source: "bs-issue-closed", tint: "purple" }); + assertEquals(notificationIcon({ ...unread, subject: { ...unread.subject, state: "merged" } }).source, "bs-pr-merged"); + assertEquals(notificationUrl(unread), "https://codeberg.org/backslash-app/backslash/pulls/57#issuecomment-99"); + assertEquals(notificationUrl(pinned), "https://codeberg.org/backslash-app/backslash/issues/40"); + assertEquals([subjectNumber(unread), subjectNumber(pinned)], ["57", "40"]); +}); + +Deno.test("token and setup row", () => { + assertEquals(token({ token: " abc " }), "abc"); + let error: unknown; + try { + token({}); + } catch (e) { + error = e; + } + assertEquals(error instanceof AuthError, true); + const [row] = setupItems(new AuthError("Add a Codeberg access token")); + assertEquals([row.id, row.actions?.[0].title], ["setup", "Create Access Token"]); +}); + +Deno.test("formatting", () => { + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals([ago("2026-10-28T09:00:00Z", now), ago("2026-10-27T09:00:00Z", now), ago("2026-10-24T09:00:00Z", now)], [ + "today", + "yesterday", + "4d", + ]); + assertEquals([compact(950), compact(1234), compact(45_600)], ["950", "1.2k", "46k"]); + assertEquals([colorOf("ee0701"), colorOf("#0e8a16"), colorOf("cccccc"), colorOf(undefined)], [ + "red", + "green", + "gray", + "gray", + ]); +}); diff --git a/extensions/countdowns/backslash.toml b/extensions/countdowns/backslash.toml new file mode 100644 index 0000000..aec7f6f --- /dev/null +++ b/extensions/countdowns/backslash.toml @@ -0,0 +1,24 @@ +[extension] +id = "countdowns" +title = "Countdowns" +description = "Count down to vacations, launches and birthdays, and pin the next one to the root search" +version = "1.0.0" +author = "Backslash" +icon = "bs-hourglass" +license = "MIT" + +# Countdowns live in the extension's own storage: no permission needed. + +[[commands]] +name = "countdowns" +title = "Countdowns" +description = "Days left to your events, soonest first; pin one to see it here" +icon = "bs-hourglass" +keywords = ["countdown", "days until", "event", "birthday", "anniversary", "vacation", "compte à rebours", "jours", "anniversaire", "vacances"] + +[[commands]] +name = "add" +title = "Add Countdown" +description = "A date to count down to, once or every year" +icon = "bs-plus" +keywords = ["countdown", "new", "add", "event", "birthday", "date", "compte à rebours", "ajouter", "anniversaire", "événement"] diff --git a/extensions/countdowns/commands/add.ts b/extensions/countdowns/commands/add.ts new file mode 100644 index 0000000..d218086 --- /dev/null +++ b/extensions/countdowns/commands/add.ts @@ -0,0 +1,5 @@ +import type { Context } from "@backslash/api"; +import { countdownForm } from "../countdowns.ts"; + +// "Add Countdown Vacation" opens the form with the title filled in. +export default (ctx: Context) => countdownForm({ title: ctx.query.trim(), standalone: true }); diff --git a/extensions/countdowns/commands/countdowns.ts b/extensions/countdowns/commands/countdowns.ts new file mode 100644 index 0000000..cc75dae --- /dev/null +++ b/extensions/countdowns/commands/countdowns.ts @@ -0,0 +1,30 @@ +import { action, Icons, list } from "@backslash/api"; +import { sortCountdowns } from "../lib.ts"; +import { countdownForm, countdownItem, live, loadAll, pinnedId, suggestionItems, syncSubtitle } from "../countdowns.ts"; + +let footer = ""; + +export default list({ + placeholder: "Search countdowns…", + empty: { title: "No countdown", description: "Press Ctrl+N to add one", icon: Icons.hourglass }, + status: () => footer, + actions: [action("New Countdown", () => countdownForm(), { icon: Icons.plus, shortcut: "n" })], + items: ({ signal }) => + live(async () => { + const now = new Date(); + const [all, pinned] = await Promise.all([loadAll(), pinnedId()]); + await syncSubtitle(all, pinned ?? ""); + if (!all.length) { + footer = "Add a date to count down to"; + return suggestionItems(now); + } + const sorted = sortCountdowns(all, now); + const upcoming = sorted.filter((c) => countdownItem(c, now, false).section !== "Past").length; + footer = `${upcoming} upcoming${all.length > upcoming ? ` · ${all.length - upcoming} past` : ""}`; + // The pinned one comes first, in its own section. + return [ + ...sorted.filter((c) => c.id === pinned).map((c) => ({ ...countdownItem(c, now, true), section: "Pinned" })), + ...sorted.filter((c) => c.id !== pinned).map((c) => countdownItem(c, now, false)), + ]; + }, signal), +}); diff --git a/extensions/countdowns/countdowns.ts b/extensions/countdowns/countdowns.ts new file mode 100644 index 0000000..0da7e26 --- /dev/null +++ b/extensions/countdowns/countdowns.ts @@ -0,0 +1,276 @@ +// Countdowns kept in the extension's storage, and their launcher items. + +import { + type Action, + action, + close, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + pop, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + breakdown, + calendarDays, + type Color, + COLORS, + type Countdown, + eventDate, + formatDate, + newId, + nextOccurrence, + ordinal, + parseDateInput, + parseTimeInput, + pinnedSubtitle, + progress, + sectionOf, + timeLeft, + yearsAt, +} from "./lib.ts"; + +export async function loadAll(): Promise { + const all = (await storage.get("countdowns").catch(() => undefined)) as Countdown[] | undefined; + return Array.isArray(all) ? all.filter((c) => c && typeof c.date === "string" && typeof c.title === "string") : []; +} + +async function saveAll(all: Countdown[]) { + await storage.set("countdowns", all as unknown as Parameters[1]); + notifyChanged(); +} + +export async function pinnedId(): Promise { + const id = await storage.get("pinned").catch(() => undefined); + return typeof id === "string" ? id : undefined; +} + +/** Shows the pinned countdown under the command in the root search, or clears it. */ +export async function syncSubtitle(all?: Countdown[], pinned?: string) { + const list = all ?? await loadAll(); + const id = pinned ?? await pinnedId(); + const c = list.find((x) => x.id === id); + await updateCommandMetadata({ subtitle: c ? pinnedSubtitle(c, new Date()) : null }).catch(() => {}); +} + +export async function setPinned(id: string | undefined) { + if (id) await storage.set("pinned", id); + else await storage.delete("pinned"); + await syncSubtitle(undefined, id ?? ""); + notifyChanged(); +} + +export async function upsert(c: Countdown) { + const all = await loadAll(); + const i = all.findIndex((x) => x.id === c.id); + if (i >= 0) all[i] = c; + else all.push(c); + await saveAll(all); + await syncSubtitle(all); +} + +export async function remove(c: Countdown) { + await saveAll((await loadAll()).filter((x) => x.id !== c.id)); + if ((await pinnedId()) === c.id) await setPinned(undefined); +} + +// --- Live reload ------------------------------------------------------------- + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Yields `load()` after each change and every minute (the labels move), until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, 60_000 - (Date.now() % 60_000) + 50); + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --- Form -------------------------------------------------------------------- + +const COLOR_NAMES = COLORS.map((c) => ({ value: c, title: c[0].toUpperCase() + c.slice(1) })); + +export function countdownForm( + o: { countdown?: Countdown; title?: string; date?: string; yearly?: boolean; standalone?: boolean } = {}, +): FormSpec { + const c = o.countdown; + return form({ + title: c ? `Edit “${c.title}”` : "New Countdown", + submitTitle: c ? "Save Countdown" : "Add Countdown", + fields: [ + field.text("title", "Title", { + required: true, + placeholder: "Vacation, Mom's birthday…", + value: c?.title ?? o.title ?? "", + }), + field.text("date", "Date", { + required: true, + placeholder: "2026-12-24, Dec 24, 24 December 2027", + value: c?.date ?? o.date ?? "", + }), + field.text("time", "Time", { placeholder: "Optional: 18:30, 6pm", value: c?.time ?? "" }), + field.checkbox("yearly", "Repeats every year (birthdays, anniversaries)", { + value: c?.yearly ?? o.yearly ?? false, + }), + field.dropdown("color", "Color", COLOR_NAMES, { value: c?.color ?? "blue" }), + ], + async submit(values) { + const now = new Date(); + const title = String(values.title ?? "").trim(); + if (!title) throw new Error("Give the countdown a title"); + const date = parseDateInput(String(values.date ?? ""), now); + if (!date) throw new Error(`“${values.date}” is not a date (2026-12-24, Dec 24, 24 December 2027)`); + const time = parseTimeInput(String(values.time ?? "")); + const color = (COLORS as string[]).includes(String(values.color)) ? values.color as Color : "blue"; + const next: Countdown = { + id: c?.id ?? newId(), + title, + date, + ...(time ? { time } : {}), + ...(values.yearly === true ? { yearly: true } : {}), + color, + created: c?.created ?? now.getTime(), + }; + await upsert(next); + const left = timeLeft(next, now); + await toast(c ? "Countdown saved" : `${title} ${left.label}`, { style: "success" }); + return o.standalone ? close(`${title} ${left.label}`) : pop(); + }, + }); +} + +// --- Items ------------------------------------------------------------------- + +export function countdownItem(c: Countdown, now: Date, pinned: boolean): Item { + const at = nextOccurrence(c, now); + const left = timeLeft(c, now); + const color: Color = left.past ? "gray" : c.color ?? "blue"; + const years = yearsAt(c, now); + const ratio = progress(c, now); + const hours = Math.round(left.ms / 3_600_000); + const actions: Action[] = [ + action(pinned ? "Unpin from Root Search" : "Pin to Root Search", async () => { + await setPinned(pinned ? undefined : c.id); + await toast(pinned ? "Unpinned" : `“${pinnedSubtitle(c, new Date())}” shows in the root search`, { + style: "success", + }); + }, { icon: Icons.pin, shortcut: "p" }), + action("Edit Countdown", () => countdownForm({ countdown: c }), { icon: Icons.text, shortcut: "e" }), + action("New Countdown", () => countdownForm(), { icon: Icons.plus, shortcut: "n" }), + copy(pinnedSubtitle(c, now), "Copy Time Left", { shortcut: "c", section: "Copy" }), + copy(`${c.title}: ${formatDate(c, at, now)}`, "Copy Date", { section: "Copy" }), + action("Delete Countdown", async () => { + await remove(c); + await toast(`Deleted “${c.title}”`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${c.title}”?`, + section: "Danger", + shortcut: "Delete", + }), + ]; + const stats = left.past ? [{ label: "days ago", value: -left.days, color: "gray" as const }] : [ + { + label: left.days === 0 && c.time ? "hours left" : "days left", + value: left.days === 0 && c.time ? Math.max(0, hours) : left.days, + color, + }, + ...(left.days >= 14 ? [{ label: "weeks", value: Math.floor(left.days / 7) }] : []), + ...(left.days > 0 && left.days < 14 ? [{ label: "hours", value: hours }] : []), + { label: "elapsed", value: `${Math.round(ratio * 100)} %` }, + ]; + return { + id: c.id, + title: c.title, + subtitle: formatDate(c, at, now), + icon: pinned + ? { source: Icons.pin, tint: color } + : { source: left.past ? Icons.done : Icons.hourglass, tint: color }, + section: sectionOf(c, now), + keywords: [c.date, ...(c.yearly ? ["yearly", "birthday", "anniversary"] : [])], + accessories: [ + ...(c.yearly ? [{ icon: Icons.repeat, tooltip: years ? `${ordinal(years)} time` : "Every year" }] : []), + { text: left.label, color: left.past ? undefined : left.days < 7 ? color : undefined }, + ], + detail: { + stats, + metadata: [ + meta.text("Date", formatDate(c, at, now, { year: true }), { icon: Icons.calendar, color }), + ...(left.past ? [] : [ + meta.text("Time left", left.days === 0 ? left.label.replace(/^in /, "") : breakdown(now, at), { + icon: Icons.hourglass, + }), + ]), + ...(c.yearly + ? [ + meta.text("Repeats", years ? `Every year · ${ordinal(years)} this time` : "Every year", { + icon: Icons.repeat, + }), + ] + : []), + ...(c.yearly && calendarDays(eventDate(c), now) > 0 + ? [meta.text("First", eventDate(c).toLocaleDateString("en", { dateStyle: "medium" }))] + : []), + meta.progress(c.yearly ? "Since the last one" : "Since added", ratio), + meta.separator(), + meta.text("Added", new Date(c.created).toLocaleDateString("en", { dateStyle: "medium" }), { icon: Icons.plus }), + ...(pinned ? [meta.text("Pinned", "Shown in the root search", { icon: Icons.pin, color: "yellow" })] : []), + ], + }, + actions, + }; +} + +export function suggestionItems(now: Date): Item[] { + const ny = { title: `New Year ${now.getFullYear() + 1}`, date: `${now.getFullYear() + 1}-01-01` }; + const nyLeft = calendarDays(now, new Date(now.getFullYear() + 1, 0, 1)); + return [ + { + id: "suggest-new-year", + title: ny.title, + subtitle: `Jan 1 · in ${nyLeft} days`, + icon: { source: Icons.sparkle, tint: "yellow" }, + section: "Suggestions", + actions: [ + action("Add Countdown", async () => { + await upsert({ id: newId(), title: ny.title, date: ny.date, color: "yellow", created: Date.now() }); + await toast(`${ny.title} in ${nyLeft} days`, { style: "success" }); + }, { icon: Icons.plus }), + ], + }, + { + id: "suggest-birthday", + title: "A Birthday…", + subtitle: "Repeats every year and counts the years", + icon: { source: Icons.heart, tint: "pink" }, + section: "Suggestions", + actions: [action("New Birthday", () => countdownForm({ yearly: true }), { icon: Icons.plus })], + }, + { + id: "suggest-new", + title: "New Countdown…", + subtitle: "A vacation, a launch, a deadline", + icon: { source: Icons.plus, tint: "blue" }, + section: "Suggestions", + actions: [action("New Countdown", () => countdownForm(), { icon: Icons.plus })], + }, + ]; +} diff --git a/extensions/countdowns/lib.ts b/extensions/countdowns/lib.ts new file mode 100644 index 0000000..33de0e8 --- /dev/null +++ b/extensions/countdowns/lib.ts @@ -0,0 +1,289 @@ +// Countdown arithmetic: next occurrences (yearly repeats, leap days), time +// left in calendar days (safe across daylight saving changes), progress and +// the labels shown. Pure functions on local dates, no I/O. + +export type Color = "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "purple" | "pink" | "gray"; +export const COLORS: Color[] = ["blue", "green", "orange", "red", "purple", "pink", "teal", "yellow", "gray"]; + +export interface Countdown { + id: string; + title: string; + /** YYYY-MM-DD; for a yearly event, its first occurrence (a birth date). */ + date: string; + /** HH:MM, local time; none for an all-day event. */ + time?: string; + yearly?: boolean; + color?: Color; + /** When it was added (ms), the start of its progress bar. */ + created: number; +} + +const DAY = 86_400_000; +const p2 = (n: number) => String(n).padStart(2, "0"); + +export const isoDay = (d: Date) => `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; + +export const isLeapYear = (y: number) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; + +/** Days of a month, `month` from 1. */ +export const daysInMonth = (y: number, month: number) => new Date(Date.UTC(y, month, 0)).getUTCDate(); + +/** Calendar days from `a` to `b`, by their local dates: 23 or 25 hour days still count as one. */ +export function calendarDays(a: Date, b: Date): number { + const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); + const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); + return Math.round((ub - ua) / DAY); +} + +function parts(c: Pick) { + const [y, m, d] = c.date.split("-").map(Number); + const [hh, mm] = c.time ? c.time.split(":").map(Number) : [0, 0]; + return { y, m, d, hh, mm }; +} + +/** The event in a given year; Feb 29 falls on Feb 28 in other years. */ +export function inYear(c: Pick, year: number): Date { + const { m, d, hh, mm } = parts(c); + return new Date(year, m - 1, Math.min(d, daysInMonth(year, m)), hh, mm); +} + +/** The moment of the event; all-day events start at midnight. */ +export function eventDate(c: Pick): Date { + const { y } = parts(c); + return inYear(c, y); +} + +/** An all-day event lasts until the end of its day, a timed one until its minute. */ +function isOver(c: Pick, at: Date, now: Date): boolean { + return c.time ? at.getTime() <= now.getTime() - 60_000 : calendarDays(now, at) < 0; +} + +/** The occurrence counted down to: the date itself, or for a yearly event the next one (today included). */ +export function nextOccurrence(c: Pick, now: Date): Date { + if (!c.yearly) return eventDate(c); + const { y } = parts(c); + let year = Math.max(now.getFullYear(), y); + let at = inYear(c, year); + if (isOver(c, at, now)) at = inYear(c, ++year); + return at; +} + +export function previousOccurrence(c: Pick, now: Date): Date | undefined { + if (!c.yearly) return undefined; + const next = nextOccurrence(c, now); + const { y } = parts(c); + return next.getFullYear() - 1 >= y ? inYear(c, next.getFullYear() - 1) : undefined; +} + +/** For yearly events: how many years the next occurrence marks (30 for a 30th birthday). */ +export function yearsAt(c: Countdown, now: Date): number | undefined { + if (!c.yearly) return undefined; + const n = nextOccurrence(c, now).getFullYear() - parts(c).y; + return n > 0 ? n : undefined; +} + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? + "th"; + return `${n}${s}`; +} + +export interface Left { + /** Calendar days until the occurrence (negative when past). */ + days: number; + ms: number; + past: boolean; + /** "in 12 days", "tomorrow", "today", "in 3 hours", "now", "2 days ago". */ + label: string; +} + +const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`; + +export function timeLeft(c: Countdown, now: Date): Left { + const at = nextOccurrence(c, now); + const days = calendarDays(now, at); + const ms = at.getTime() - now.getTime(); + const past = isOver(c, at, now); + let label: string; + if (c.time && (days === 0 || Math.abs(ms) < 6 * 3_600_000)) { + const minutes = Math.round(Math.abs(ms) / 60_000); + if (minutes < 1 || (!past && ms <= 0)) label = "now"; + else { + const span = minutes < 60 ? plural(minutes, "minute") : plural(Math.round(minutes / 60), "hour"); + label = past ? `${span} ago` : `in ${span}`; + } + } else if (days === 0) label = "today"; + else if (days === 1) label = "tomorrow"; + else if (days === -1) label = "yesterday"; + else if (days > 0) label = `in ${plural(days, "day")}`; + else label = `${plural(-days, "day")} ago`; + return { days, ms, past, label }; +} + +export type Section = "This Week" | "This Month" | "Later" | "Past"; +export const SECTIONS: Section[] = ["This Week", "This Month", "Later", "Past"]; + +/** Within 7 days, within 31 days, later, or past. */ +export function sectionOf(c: Countdown, now: Date): Section { + const left = timeLeft(c, now); + if (left.past) return "Past"; + return left.days < 7 ? "This Week" : left.days < 31 ? "This Month" : "Later"; +} + +/** Soonest first, then the past ones, most recent first. */ +export function sortCountdowns(list: Countdown[], now: Date): Countdown[] { + const key = (c: Countdown) => nextOccurrence(c, now).getTime(); + return [...list].sort((a, b) => { + const pa = timeLeft(a, now).past; + const pb = timeLeft(b, now).past; + if (pa !== pb) return pa ? 1 : -1; + return pa ? key(b) - key(a) : key(a) - key(b) || a.title.localeCompare(b.title); + }); +} + +/** From the creation (or the last yearly occurrence, if later) to the occurrence, 0 to 1. */ +export function progress(c: Countdown, now: Date): number { + const at = nextOccurrence(c, now).getTime(); + const prev = previousOccurrence(c, now)?.getTime(); + const start = Math.max(c.created, prev ?? -Infinity); + if (at <= start) return now.getTime() >= at ? 1 : 0; + return Math.max(0, Math.min(1, (now.getTime() - start) / (at - start))); +} + +/** "2 months, 1 week, 3 days" in calendar terms, for the detail panel. */ +export function breakdown(now: Date, at: Date): string { + let from = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let to = new Date(at.getFullYear(), at.getMonth(), at.getDate()); + if (to < from) [from, to] = [to, from]; + let months = (to.getFullYear() - from.getFullYear()) * 12 + to.getMonth() - from.getMonth(); + const shifted = (n: number) => { + const first = new Date(from.getFullYear(), from.getMonth() + n, 1); + first.setDate(Math.min(from.getDate(), daysInMonth(first.getFullYear(), first.getMonth() + 1))); + return first; + }; + if (shifted(months) > to) months--; + let days = calendarDays(shifted(months), to); + const years = Math.floor(months / 12); + months %= 12; + const weeks = Math.floor(days / 7); + days %= 7; + const out = [ + years ? plural(years, "year") : "", + months ? plural(months, "month") : "", + weeks ? plural(weeks, "week") : "", + days ? plural(days, "day") : "", + ].filter(Boolean); + return out.length ? out.join(", ") : "Today"; +} + +/** What the root search shows for the pinned countdown: "Vacation in 12 days". */ +export function pinnedSubtitle(c: Countdown, now: Date): string { + return `${c.title} ${timeLeft(c, now).label}`; +} + +// --- Input ------------------------------------------------------------------- + +const MONTHS = [ + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", +]; + +/** `dec`, `Dec.`, `december` → 12. */ +function month(word: string): number | undefined { + const w = word.toLowerCase().replace(/\.$/, ""); + if (w.length < 3) return undefined; + const i = MONTHS.findIndex((m) => m.startsWith(w)); + return i >= 0 ? i + 1 : undefined; +} + +function valid(y: number, m: number, d: number) { + return m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m); +} + +/** + * A date typed by a person: `2026-12-25`, `12-25` or `Dec 25` / `25 December` + * (the next one), `Dec 25 2027`, `25 Dec 2027`, `today`, `tomorrow`. + * Returns YYYY-MM-DD or undefined. + */ +export function parseDateInput(input: string, now: Date): string | undefined { + const s = input.trim().toLowerCase().replace(/,/g, " ").replace(/\s+/g, " "); + if (!s) return undefined; + const iso = (y: number, m: number, d: number) => valid(y, m, d) ? `${y}-${p2(m)}-${p2(d)}` : undefined; + if (s === "today") return isoDay(now); + if (s === "tomorrow") return isoDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)); + let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s); + if (m) return iso(+m[1], +m[2], +m[3]); + // Without a year: the next one (today included). + const upcoming = (mo: number, d: number) => { + const y = now.getFullYear(); + if (!valid(2024, mo, d)) return undefined; // a leap year: Feb 29 exists + for (let year = y; year < y + 8; year++) { + if (!valid(year, mo, d)) continue; + if (calendarDays(now, new Date(year, mo - 1, d)) >= 0) return iso(year, mo, d); + } + return undefined; + }; + m = /^(\d{1,2})-(\d{1,2})$/.exec(s); + if (m) return upcoming(+m[1], +m[2]); + const words = s.split(" "); + // "dec 25", "dec 25 2027", "25 dec", "25 dec 2027", "december 25th" + const num = (w: string | undefined) => w && /^\d{1,2}(st|nd|rd|th)?$/.test(w) ? parseInt(w) : undefined; + const year = (w: string | undefined) => w && /^\d{4}$/.test(w) ? +w : undefined; + if (words.length === 2 || words.length === 3) { + const [a, b, c] = words; + let mo = month(a), d = num(b); + if (!mo || !d) [mo, d] = [month(b), num(a)]; + if (mo && d) { + if (words.length === 2) return upcoming(mo, d); + const y = year(c); + if (y) return iso(y, mo, d); + } + } + return undefined; +} + +/** `14:30`, `9:05`, `2pm`, `2:30 pm`, `noon`; empty → undefined; throws when not understood. */ +export function parseTimeInput(input: string): string | undefined { + const s = input.trim().toLowerCase().replace(/\s+/g, ""); + if (!s) return undefined; + if (s === "noon") return "12:00"; + if (s === "midnight") return "00:00"; + const m = /^(\d{1,2})(?:[:h.](\d{2}))?(am|pm)?$/.exec(s); + if (!m) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + let h = +m[1]; + const min = m[2] ? +m[2] : 0; + if (m[3]) { + if (h < 1 || h > 12) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + h = (h % 12) + (m[3] === "pm" ? 12 : 0); + } + if (h > 23 || min > 59) throw new Error(`“${input}” is not a time (18:30, 6pm)`); + return `${p2(h)}:${p2(min)}`; +} + +export function formatDate(c: Pick, at: Date, now: Date, o: { year?: boolean } = {}): string { + const date = at.toLocaleDateString("en", { + weekday: "short", + month: "short", + day: "numeric", + year: o.year || at.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + return c.time ? `${date}, ${c.time}` : date; +} + +export function newYear(now: Date): { title: string; date: string } { + return { title: `New Year ${now.getFullYear() + 1}`, date: `${now.getFullYear() + 1}-01-01` }; +} + +export function newId(now = Date.now()): string { + return `${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`; +} diff --git a/extensions/countdowns/lib_test.ts b/extensions/countdowns/lib_test.ts new file mode 100644 index 0000000..415dfd1 --- /dev/null +++ b/extensions/countdowns/lib_test.ts @@ -0,0 +1,199 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + breakdown, + calendarDays, + type Countdown, + daysInMonth, + inYear, + isLeapYear, + nextOccurrence, + ordinal, + parseDateInput, + parseTimeInput, + pinnedSubtitle, + previousOccurrence, + progress, + sectionOf, + sortCountdowns, + timeLeft, + yearsAt, +} from "./lib.ts"; + +// Local times: the results must not depend on the machine's time zone. +const NOW = new Date(2026, 9, 28, 10, 0); // Wed 28 Oct 2026, 10:00 +const cd = (date: string, extra: Partial = {}): Countdown => ({ + id: date, + title: "Vacation", + date, + created: new Date(2026, 9, 18, 10, 0).getTime(), + ...extra, +}); +const day = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()]; + +Deno.test("leap years and month lengths", () => { + assertEquals([2024, 2025, 2100, 2000].map(isLeapYear), [true, false, false, true]); + assertEquals(daysInMonth(2024, 2), 29); + assertEquals(daysInMonth(2026, 2), 28); + assertEquals(daysInMonth(2026, 12), 31); +}); + +Deno.test("calendar days ignore daylight saving changes", () => { + // The night of a DST change is 23 or 25 hours long in many zones; it is still one day. + assertEquals(calendarDays(new Date(2026, 2, 28, 12), new Date(2026, 2, 29, 12)), 1); + assertEquals(calendarDays(new Date(2026, 2, 28, 23, 59), new Date(2026, 2, 29, 0, 1)), 1); + assertEquals(calendarDays(new Date(2026, 9, 24, 0, 30), new Date(2026, 9, 26, 0, 0)), 2); + assertEquals(calendarDays(new Date(2026, 2, 1), new Date(2026, 9, 31)), 244); + assertEquals(calendarDays(new Date(2024, 1, 28), new Date(2024, 2, 1)), 2); + assertEquals(calendarDays(new Date(2026, 1, 28), new Date(2026, 2, 1)), 1); +}); + +Deno.test("labels for all-day events", () => { + const label = (date: string) => timeLeft(cd(date), NOW).label; + assertEquals(label("2026-11-09"), "in 12 days"); + assertEquals(label("2026-10-29"), "tomorrow"); + assertEquals(label("2026-10-28"), "today"); + assertEquals(label("2026-10-27"), "yesterday"); + assertEquals(label("2026-10-20"), "8 days ago"); + assertEquals(label("2026-10-30"), "in 2 days"); + assert(!timeLeft(cd("2026-10-28"), NOW).past, "an all-day event lasts the whole day"); + assert(timeLeft(cd("2026-10-27"), NOW).past); +}); + +Deno.test("labels for timed events", () => { + const label = (date: string, time: string, now = NOW) => timeLeft(cd(date, { time }), now).label; + assertEquals(label("2026-10-28", "13:00"), "in 3 hours"); + assertEquals(label("2026-10-28", "10:20"), "in 20 minutes"); + assertEquals(label("2026-10-28", "10:00"), "now"); + assertEquals(label("2026-10-28", "08:00"), "2 hours ago"); + assertEquals(label("2026-10-29", "02:00", new Date(2026, 9, 28, 23, 0)), "in 3 hours"); + assertEquals(label("2026-10-29", "18:00"), "tomorrow"); + assertEquals(label("2026-11-09", "18:00"), "in 12 days"); + // A DST night between now and the event doesn't change the day count. + assertEquals(label("2026-03-30", "09:00", new Date(2026, 2, 28, 9, 0)), "in 2 days"); + assertEquals(label("2026-03-29", "12:00", new Date(2026, 2, 28, 12, 0)), "tomorrow"); +}); + +Deno.test("yearly events: next occurrence, today included", () => { + const birthday = cd("1990-11-09", { yearly: true }); + assertEquals(day(nextOccurrence(birthday, NOW)), [2026, 11, 9, 0, 0]); + assertEquals(yearsAt(birthday, NOW), 36); + assertEquals(timeLeft(birthday, NOW).label, "in 12 days"); + const passed = cd("1990-03-02", { yearly: true }); + assertEquals(day(nextOccurrence(passed, NOW)), [2027, 3, 2, 0, 0]); + assertEquals(day(previousOccurrence(passed, NOW)!), [2026, 3, 2, 0, 0]); + const today = cd("2000-10-28", { yearly: true }); + assertEquals(timeLeft(today, NOW).label, "today"); + assertEquals(yearsAt(today, NOW), 26); + // A yearly event set in the future starts that year. + const future = cd("2027-06-01", { yearly: true }); + assertEquals(day(nextOccurrence(future, NOW)), [2027, 6, 1, 0, 0]); + assertEquals(yearsAt(future, NOW), undefined); + assertEquals(previousOccurrence(future, NOW), undefined); + // A timed yearly event that already happened today moves on to next year. + const morning = cd("2020-10-28", { yearly: true, time: "08:00" }); + assertEquals(day(nextOccurrence(morning, NOW)), [2027, 10, 28, 8, 0]); +}); + +Deno.test("leap-day birthdays fall on Feb 28 in other years", () => { + const leap = cd("2000-02-29", { yearly: true }); + assertEquals(day(inYear(leap, 2027)), [2027, 2, 28, 0, 0]); + assertEquals(day(inYear(leap, 2028)), [2028, 2, 29, 0, 0]); + assertEquals(day(nextOccurrence(leap, NOW)), [2027, 2, 28, 0, 0]); + assertEquals(day(nextOccurrence(leap, new Date(2028, 1, 1))), [2028, 2, 29, 0, 0]); + assertEquals(timeLeft(leap, new Date(2027, 1, 28, 9)).label, "today"); + assertEquals(yearsAt(leap, NOW), 27); +}); + +Deno.test("sections and order", () => { + const list = [ + cd("2026-12-25", { id: "xmas" }), + cd("2026-10-30", { id: "soon" }), + cd("2026-11-15", { id: "month" }), + cd("2026-10-01", { id: "old" }), + cd("2026-10-20", { id: "recent" }), + cd("1990-11-01", { id: "bday", yearly: true }), + ]; + assertEquals(list.map((c) => sectionOf(c, NOW)), ["Later", "This Week", "This Month", "Past", "Past", "This Week"]); + assertEquals(sortCountdowns(list, NOW).map((c) => c.id), ["soon", "bday", "month", "xmas", "recent", "old"]); +}); + +Deno.test("progress since creation, or since the last yearly occurrence", () => { + // Created on Oct 18, due Nov 7 at midnight: half-way on Oct 28 at 00:00. + const c = cd("2026-11-07"); + const ratio = progress(c, NOW); + assert(ratio > 0.5 && ratio < 0.53, String(ratio)); + assertEquals(progress(cd("2026-10-01"), NOW), 1); + assertEquals(progress(cd("2026-12-01", { created: NOW.getTime() }), NOW), 0); + // Yearly: from last year's occurrence (later than the creation date here). + const yearly = cd("1990-04-28", { yearly: true, created: new Date(2020, 0, 1).getTime() }); + const r = progress(yearly, NOW); + assert(r > 0.49 && r < 0.51, String(r)); +}); + +Deno.test("breakdown in calendar units", () => { + assertEquals(breakdown(NOW, new Date(2026, 9, 28, 18)), "Today"); + assertEquals(breakdown(NOW, new Date(2026, 10, 9)), "1 week, 5 days"); + assertEquals(breakdown(NOW, new Date(2027, 0, 1)), "2 months, 4 days"); + assertEquals(breakdown(NOW, new Date(2028, 0, 1)), "1 year, 2 months, 4 days"); + assertEquals(breakdown(new Date(2026, 0, 31), new Date(2026, 2, 1)), "1 month, 1 day"); + assertEquals(breakdown(new Date(2026, 10, 9), NOW), "1 week, 5 days"); +}); + +Deno.test("dates and times typed in the form", () => { + assertEquals(parseDateInput("2026-12-24", NOW), "2026-12-24"); + assertEquals(parseDateInput("2026-2-3", NOW), "2026-02-03"); + assertEquals(parseDateInput("12-24", NOW), "2026-12-24"); + assertEquals(parseDateInput("03-01", NOW), "2027-03-01"); // next one + assertEquals(parseDateInput("10-28", NOW), "2026-10-28"); // today counts + assertEquals(parseDateInput("Dec 24", NOW), "2026-12-24"); + assertEquals(parseDateInput("24 december", NOW), "2026-12-24"); + assertEquals(parseDateInput("December 24th, 2027", NOW), "2027-12-24"); + assertEquals(parseDateInput("24 Dec. 2027", NOW), "2027-12-24"); + assertEquals(parseDateInput("Feb 29", NOW), "2028-02-29"); // the next leap day + assertEquals(parseDateInput("2027-02-29", NOW), undefined); + assertEquals(parseDateInput("tomorrow", NOW), "2026-10-29"); + assertEquals(parseDateInput("soon", NOW), undefined); + assertEquals(parseDateInput("13-01", NOW), undefined); + assertEquals(parseTimeInput(""), undefined); + assertEquals(parseTimeInput("18:30"), "18:30"); + assertEquals(parseTimeInput("9:05"), "09:05"); + assertEquals(parseTimeInput("6pm"), "18:00"); + assertEquals(parseTimeInput("12 am"), "00:00"); + assertEquals(parseTimeInput("12:15pm"), "12:15"); + assertEquals(parseTimeInput("18h30"), "18:30"); + assertThrows(() => parseTimeInput("25:00")); + assertThrows(() => parseTimeInput("13pm")); + assertThrows(() => parseTimeInput("later")); +}); + +Deno.test("root search subtitle and ordinals", () => { + assertEquals(pinnedSubtitle(cd("2026-11-09"), NOW), "Vacation in 12 days"); + assertEquals(pinnedSubtitle(cd("2026-10-28"), NOW), "Vacation today"); + assertEquals([1, 2, 3, 4, 11, 12, 13, 21, 22, 101, 111].map(ordinal), [ + "1st", + "2nd", + "3rd", + "4th", + "11th", + "12th", + "13th", + "21st", + "22nd", + "101st", + "111th", + ]); +}); + +Deno.test("item: sections, stats and actions", async () => { + const { countdownItem } = await import("./countdowns.ts"); + const item = countdownItem(cd("2026-11-09", { color: "green" }), NOW, false); + assertEquals(item.section, "This Month"); + assertEquals(item.accessories?.at(-1)?.text, "in 12 days"); + assertEquals(item.detail?.stats?.[0], { label: "days left", value: 12, color: "green" }); + assertEquals(item.actions?.map((a) => a.title).slice(0, 3), [ + "Pin to Root Search", + "Edit Countdown", + "New Countdown", + ]); + assert(item.actions?.some((a) => a.destructive && a.confirm)); +}); diff --git a/extensions/cron/backslash.toml b/extensions/cron/backslash.toml new file mode 100644 index 0000000..52b5f85 --- /dev/null +++ b/extensions/cron/backslash.toml @@ -0,0 +1,31 @@ +[extension] +id = "cron" +title = "Cron Helper" +description = "Explain cron and systemd calendar expressions, see their next runs and convert between them" +version = "1.0.0" +author = "Backslash" +icon = "bs-clock" +license = "MIT" + +# No permission: everything is computed locally. + +[[commands]] +name = "cron" +title = "Cron Helper" +description = "Type a cron expression or an OnCalendar value: English, next 10 runs, systemd and cron equivalents" +icon = "bs-clock" +keywords = [ + "cron", + "crontab", + "schedule", + "systemd", + "timer", + "oncalendar", + "quartz", + "next run", + "expression", + "planifier", + "planification", + "tâche planifiée", + "minuteur", +] diff --git a/extensions/cron/commands/cron.ts b/extensions/cron/commands/cron.ts new file mode 100644 index 0000000..03848e4 --- /dev/null +++ b/extensions/cron/commands/cron.ts @@ -0,0 +1,250 @@ +import { type Action, copy, Icons, type Item, list, meta, type Metadata } from "@backslash/api"; +import { + explain, + fieldLines, + formatRun, + nextRuns, + parseAny, + parseCron, + PRESETS, + type Schedule, + timerUnit, + toCron, + toSystemd, + until, +} from "../lib.ts"; + +const attempt = (fn: () => T): T | Error => { + try { + return fn(); + } catch (e) { + return e instanceof Error ? e : new Error(String(e)); + } +}; + +const runsMarkdown = (s: Schedule, now: Date, count = 5) => { + if (s.reboot) return "Runs once at boot. Cron daemons that support it (cronie, Vixie cron) start it when they start."; + const runs = nextRuns(s, now, count); + if (!runs.length) return "_Never runs again._"; + return ["**Next runs** (local time)", "", ...runs.map((d) => `- ${formatRun(d, s.hasSeconds)} · ${until(d, now)}`)] + .join("\n"); +}; + +function conversions(s: Schedule): { systemd: string[] | Error; cron: string | Error } { + return { systemd: attempt(() => toSystemd(s)), cron: attempt(() => toCron(s)) }; +} + +function scheduleMetadata(s: Schedule): Metadata[] { + const { systemd, cron } = conversions(s); + return [ + ...fieldLines(s).map(([label, value]) => meta.text(label, value)), + meta.separator(), + ...(s.kind === "systemd" && typeof cron === "string" ? [meta.text("cron", cron, { icon: Icons.terminal })] : []), + ...(s.kind === "cron" && Array.isArray(systemd) + ? systemd.map((line, i) => meta.text(i ? "" : "OnCalendar", line, { icon: i ? undefined : Icons.calendar })) + : []), + ...(s.timezone ? [meta.text("Time zone", `${s.timezone} (runs shown in local time)`, { icon: Icons.globe })] : []), + ]; +} + +function copyActions(s: Schedule, description: string): Action[] { + const { systemd, cron } = conversions(s); + return [ + copy(s.source, "Copy Expression"), + copy(description, "Copy Explanation", { icon: Icons.text, shortcut: "e" }), + ...(s.kind === "cron" && Array.isArray(systemd) + ? [ + copy(systemd.join("\n"), "Copy as systemd OnCalendar", { icon: Icons.calendar, shortcut: "s" }), + copy(timerUnit(systemd, description), "Copy systemd Timer Unit", { icon: Icons.file, section: "systemd" }), + ] + : []), + ...(s.kind === "systemd" && typeof cron === "string" + ? [copy(cron, "Copy as Cron Expression", { icon: Icons.terminal, shortcut: "s" })] + : []), + ]; +} + +function presetItem(cron: string, title: string, now: Date): Item { + const s = parseCron(cron); + const next = s.reboot ? undefined : nextRuns(s, now, 1)[0]; + const description = explain(s); + return { + id: `preset:${cron}`, + title: cron, + subtitle: title, + icon: { source: s.reboot ? Icons.power : Icons.clock, tint: "blue" }, + section: "Presets", + keywords: [title, description], + accessories: next ? [{ text: until(next, now), tooltip: `Next run: ${formatRun(next)}` }] : [], + detail: { + markdown: `## ${description}\n\n${runsMarkdown(s, now)}`, + metadata: scheduleMetadata(s), + }, + actions: copyActions(s, description), + }; +} + +function scheduleItems(s: Schedule, now: Date): Item[] { + const description = explain(s); + const runs = s.reboot ? [] : nextRuns(s, now, 10); + const { systemd, cron } = conversions(s); + const kind = s.kind === "cron" + ? `Cron · ${s.macro ?? `${s.hasSeconds ? s.source.split(/\s+/).length : 5} fields`}` + : `systemd calendar event${s.macro ? ` · ${s.macro}` : ""}`; + const items: Item[] = [{ + id: "explain", + title: description, + subtitle: kind, + icon: { source: Icons.calendar, tint: "blue" }, + section: "Schedule", + accessories: runs[0] ? [{ text: until(runs[0], now), tooltip: "Next run" }] : [], + detail: { + stats: runs[0] + ? [{ label: "Next run", value: until(runs[0], now).replace(/^in /, "") }, { + label: "Runs per day", + value: perDay(runs), + }] + : [], + markdown: `## ${description}\n\n\`${s.source}\`\n\n${runsMarkdown(s, now)}`, + metadata: scheduleMetadata(s), + }, + actions: copyActions(s, description), + }]; + if (s.timezone) { + items.push({ + id: "tz", + title: `Times are computed in local time, not ${s.timezone}`, + icon: { source: Icons.info, tint: "orange" }, + section: "Schedule", + }); + } + runs.forEach((d, i) => { + const iso = toLocalIso(d); + items.push({ + id: `run:${i}`, + title: formatRun(d, s.hasSeconds), + subtitle: until(d, now), + icon: { source: Icons.clock, tint: i === 0 ? "green" : "gray" }, + section: "Next Runs", + accessories: i === 0 ? [{ tag: "Next", color: "green" }] : [], + actions: [copy(iso, "Copy Date and Time"), copy(s.source, "Copy Expression")], + }); + }); + if (s.kind === "cron") { + if (Array.isArray(systemd)) { + systemd.forEach((line, i) => { + items.push({ + id: `systemd:${i}`, + title: line, + subtitle: systemd.length > 1 ? `OnCalendar= (line ${i + 1} of ${systemd.length})` : "OnCalendar=", + icon: { source: Icons.calendar, tint: "purple" }, + section: "systemd Timer", + detail: { + markdown: [ + systemd.length > 1 + ? "Cron runs when the day of month **or** the day of week matches; systemd needs one `OnCalendar=` line for each." + : "Same schedule as a systemd timer.", + "", + "```ini", + timerUnit(systemd, description), + "```", + "", + "Check it with `systemd-analyze calendar`.", + ].join("\n"), + }, + actions: [ + copy(line, "Copy OnCalendar Value"), + copy(timerUnit(systemd, description), "Copy Timer Unit", { icon: Icons.file }), + copy(`systemd-analyze calendar --iterations=5 '${line}'`, "Copy systemd-analyze Command", { + icon: Icons.terminal, + }), + ], + }); + }); + } else items.push(unsupported(systemd, "systemd Timer")); + } else if (typeof cron === "string") { + items.push({ + id: "cron", + title: cron, + subtitle: s.hasSeconds && cron.split(" ").length === 6 ? "Cron with seconds (Quartz, Spring, node-cron)" : "Cron", + icon: { source: Icons.terminal, tint: "purple" }, + section: "Cron", + detail: { markdown: `\`\`\`\n# crontab -e\n${cron} /path/to/command\n\`\`\`` }, + actions: [copy(cron, "Copy Cron Expression"), copy(`${cron} `, "Copy as crontab Line Start")], + }); + } else items.push(unsupported(cron, "Cron")); + return items; +} + +const unsupported = (e: Error, section: string): Item => ({ + id: `unsupported:${section}`, + title: e.message, + subtitle: "No exact equivalent", + icon: { source: Icons.alert, tint: "orange" }, + section, +}); + +function perDay(runs: Date[]): string { + if (runs.length < 2) return "–"; + const span = (runs[runs.length - 1].getTime() - runs[0].getTime()) / (runs.length - 1); + const n = 86_400_000 / span; + return n >= 1 ? String(Math.round(n)) : `1 per ${Math.round(1 / n)} days`; +} + +const pad = (n: number) => String(n).padStart(2, "0"); +const toLocalIso = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${ + pad(d.getSeconds()) + }`; + +export default list({ + placeholder: "Type a cron expression (*/5 * * * *) or a systemd calendar (Mon..Fri 09:00)…", + empty: { title: "Nothing to show", icon: Icons.clock }, + status: (items) => { + const first = items[0]; + if (!first) return ""; + if (first.id === "error") return "Invalid expression"; + if (first.id?.startsWith("preset:")) return `${items.length} presets · times in local time`; + return String(first.subtitle ?? ""); + }, + search(query) { + const now = new Date(); + const text = query.trim(); + const presets = (filter: string) => + PRESETS.filter((p) => !filter || `${p.cron} ${p.title}`.toLowerCase().includes(filter.toLowerCase())) + .map((p) => presetItem(p.cron, p.title, now)); + if (!text) return presets(""); + const s = attempt(() => parseAny(text)); + if (!(s instanceof Error)) return scheduleItems(s, now); + // Words that aren't an expression search the presets ("weekday", "month"). + const matching = presets(text); + if (/^[a-z ]+$/i.test(text) && matching.length) return matching; + return [{ + id: "error", + title: s.message, + subtitle: "Not a valid expression", + icon: { source: Icons.alert, tint: "red" }, + section: "Expression", + detail: { + markdown: [ + "## Cron", + "", + "`minute hour day-of-month month day-of-week`, or 6 fields with seconds first.", + "", + "- `*` any · `5` a value · `1-5` a range · `*/15` a step · `1,15` a list", + "- Names: `JAN`–`DEC`, `SUN`–`SAT`", + "- `L` last day of the month · `5L` last Friday · `1#2` second Monday", + "- `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`, `@reboot`", + "", + "## systemd", + "", + "`[weekday] [year-month-day] [hour:minute[:second]]`", + "", + "- `Mon..Fri 09:00` · `*-*-01 00:00` · `*:0/15` · `*-*~01` (last day)", + "- `daily`, `weekly`, `monthly`, `quarterly`, `yearly`", + ].join("\n"), + }, + actions: [copy(text, "Copy Input")], + }, ...presets("")]; + }, +}); diff --git a/extensions/cron/lib.ts b/extensions/cron/lib.ts new file mode 100644 index 0000000..e489bca --- /dev/null +++ b/extensions/cron/lib.ts @@ -0,0 +1,751 @@ +// Cron and systemd calendar expressions: parsing, English, next runs, conversion. Pure, tested in lib_test.ts. + +export interface Part { + kind: "any" | "value" | "range" | "step"; + from: number; + to: number; + step: number; +} + +export interface Field { + parts: Part[]; + values: number[]; + /** `*` (or `?`): no restriction. */ + star: boolean; +} + +export interface Schedule { + kind: "cron" | "systemd"; + /** The expression as typed. */ + source: string; + /** `@daily`, `weekly`… when the expression was a shorthand. */ + macro?: string; + /** `@reboot`: no calendar time at all. */ + reboot?: boolean; + hasSeconds: boolean; + second: Field; + minute: Field; + hour: Field; + day: Field; + month: Field; + /** 0 = Sunday … 6 = Saturday. */ + weekday: Field; + year?: Field; + /** Days counted from the end of the month: 0 = the last day (cron `L`, systemd `~01`). */ + lastDays: number[]; + /** Cron `5#3`: the third Friday. */ + nth: { weekday: number; n: number }[]; + /** Cron `5L`: the last Friday. */ + lastWeekday: number[]; + /** systemd: a time zone after the time (runs are still shown in local time). */ + timezone?: string; +} + +interface Range { + name: string; + min: number; + max: number; + names?: string[]; +} + +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; +const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; +export const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +export const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +const R = { + second: { name: "second", min: 0, max: 59 }, + minute: { name: "minute", min: 0, max: 59 }, + hour: { name: "hour", min: 0, max: 23 }, + day: { name: "day of month", min: 1, max: 31 }, + month: { name: "month", min: 1, max: 12, names: MONTHS }, + weekday: { name: "day of week", min: 0, max: 7, names: DAYS }, + year: { name: "year", min: 1970, max: 2199 }, +} satisfies Record; + +export const MACROS: Record = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +}; + +const any = (r: Range): Field => ({ + parts: [{ kind: "any", from: r.min, to: r.max, step: 1 }], + values: span(r.min, r.max, 1), + star: true, +}); +const single = (n: number): Field => ({ + parts: [{ kind: "value", from: n, to: n, step: 1 }], + values: [n], + star: false, +}); + +function span(from: number, to: number, step: number): number[] { + const out: number[] = []; + for (let n = from; n <= to; n += step) out.push(n); + return out; +} + +function number(text: string, r: Range): number { + const lower = text.toLowerCase(); + const named = r.names?.indexOf(lower.slice(0, 3)) ?? -1; + if (named >= 0 && /^[a-z]+$/.test(lower)) return named + (r.min === 1 ? 1 : 0); + if (!/^\d+$/.test(text)) throw new Error(`“${text}” is not a valid ${r.name}`); + const n = Number(text); + if (n < r.min || n > r.max) { + throw new Error(`${r.name[0].toUpperCase()}${r.name.slice(1)} ${n} is out of range (${r.min}–${r.max})`); + } + return n; +} + +function finish(parts: Part[], r: Range, star: boolean): Field { + const set = new Set(); + for (const p of parts) for (const n of span(p.from, p.to, p.step)) set.add(r === R.weekday && n === 7 ? 0 : n); + return { parts, values: [...set].sort((a, b) => a - b), star }; +} + +/** One cron field: `*`, `5`, `1-5`, `*\/15`, `10-50/10`, `MON-FRI`, lists of those. */ +export function parseCronField(text: string, r: Range): Field { + if (text === "*" || text === "?") return any(r); + const parts = text.split(",").map((piece): Part => { + if (!piece) throw new Error(`Empty value in the ${r.name} field`); + const [base, stepText, extra] = piece.split("/"); + if (extra !== undefined) throw new Error(`“${piece}” has two steps`); + let step = 1; + if (stepText !== undefined) { + if (!/^\d+$/.test(stepText) || Number(stepText) === 0) throw new Error(`“/${stepText}” is not a valid step`); + step = Number(stepText); + } + let from: number, to: number; + if (base === "*" || base === "?") [from, to] = [r.min, r === R.weekday ? 6 : r.max]; + else if (base.includes("-")) { + const [a, b] = base.split("-"); + [from, to] = [number(a, r), number(b, r)]; + if (from > to) { + if (r === R.weekday && to === 0) to = 7; // FRI-SUN + else throw new Error(`Range ${base} goes backwards`); + } + } else { + from = number(base, r); + // `5/15` (Quartz, Vixie): from 5 to the end. + to = stepText === undefined ? from : r === R.weekday ? 6 : r.max; + } + const kind = stepText !== undefined ? "step" : base === "*" ? "any" : from === to ? "value" : "range"; + return { kind, from, to, step }; + }); + return finish(parts, r, false); +} + +const blank = (): Omit => ({ + second: single(0), + minute: any(R.minute), + hour: any(R.hour), + day: any(R.day), + month: any(R.month), + weekday: any(R.weekday), + lastDays: [], + nth: [], + lastWeekday: [], +}); + +/** A cron expression: 5 fields, 6 with seconds first, 7 with a year last, or a `@macro`. */ +export function parseCron(input: string): Schedule { + const source = input.trim(); + if (!source) throw new Error("Type a cron expression, like */5 * * * *"); + const lower = source.toLowerCase(); + if (lower === "@reboot") { + return { ...blank(), kind: "cron", source, macro: "@reboot", reboot: true, hasSeconds: false }; + } + if (lower.startsWith("@")) { + const expanded = MACROS[lower]; + if (!expanded) throw new Error(`Unknown shorthand ${source}: use @yearly, @monthly, @weekly, @daily or @hourly`); + return { ...parseCron(expanded), source, macro: lower }; + } + const fields = source.split(/\s+/); + if (fields.length < 5) throw new Error(`A cron expression has 5 fields, this one has ${fields.length}`); + if (fields.length > 7) throw new Error(`A cron expression has 5 to 7 fields, this one has ${fields.length}`); + const hasSeconds = fields.length >= 6; + const [sec, min, hour, dom, mon, dow, year] = hasSeconds ? fields : ["0", ...fields]; + const s: Schedule = { + ...blank(), + kind: "cron", + source, + hasSeconds, + second: parseCronField(sec, R.second), + minute: parseCronField(min, R.minute), + hour: parseCronField(hour, R.hour), + month: parseCronField(mon, R.month), + }; + // Day of month: `L` (last day), `L-3` (three days before the end). + const lastDom = dom.toUpperCase().match(/^L(?:-(\d+))?$/); + if (lastDom) { + s.day = { parts: [], values: [], star: false }; + s.lastDays = [Number(lastDom[1] ?? 0)]; + } else s.day = parseCronField(dom, R.day); + // Day of week: `5#3` (third Friday), `5L` / `FRIL` (last Friday). + const nth = dow.match(/^(\w+)#([1-5])$/); + const lastDow = dow.match(/^(\w+?)L$/i); + if (nth) { + s.weekday = { parts: [], values: [], star: false }; + s.nth = [{ weekday: number(nth[1], R.weekday) % 7, n: Number(nth[2]) }]; + } else if (lastDow && dow.toUpperCase() !== "L") { + s.weekday = { parts: [], values: [], star: false }; + s.lastWeekday = [number(lastDow[1], R.weekday) % 7]; + } else s.weekday = parseCronField(dow, R.weekday); + if (year !== undefined && year !== "*") s.year = parseCronField(year, R.year); + return s; +} + +// --------------------------------------------------------------------------- +// systemd calendar events (OnCalendar=) + +export const SYSTEMD_SHORTHANDS: Record = { + minutely: "*-*-* *:*:00", + hourly: "*-*-* *:00:00", + daily: "*-*-* 00:00:00", + monthly: "*-*-01 00:00:00", + weekly: "Mon *-*-* 00:00:00", + yearly: "*-01-01 00:00:00", + annually: "*-01-01 00:00:00", + quarterly: "*-01,04,07,10-01 00:00:00", + semiannually: "*-01,07-01 00:00:00", +}; + +const SYSTEMD_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function weekdayName(text: string): number { + const i = DAYS.indexOf(text.toLowerCase().slice(0, 3)); + if (i < 0 || !/^[a-z]+$/i.test(text)) throw new Error(`“${text}” is not a day of the week`); + return i; +} + +/** `Mon..Fri,Sun` → weekdays. */ +function systemdWeekdays(text: string): Field { + const parts = text.split(",").map((piece): Part => { + const [a, b] = piece.split(".."); + const from = weekdayName(a); + if (b === undefined) return { kind: "value", from, to: from, step: 1 }; + let to = weekdayName(b); + if (to < from) to += 7; // Sat..Mon + return { kind: "range", from, to, step: 1 }; + }); + const values = [...new Set(parts.flatMap((p) => span(p.from, p.to, 1).map((n) => n % 7)))].sort((a, b) => a - b); + return { parts, values, star: false }; +} + +/** One component of a systemd date or time: `*`, `5`, `1..5`, `0/15`, `1..20/5`, lists. */ +export function parseSystemdComponent(text: string, r: Range): Field { + if (text === "*") return any(r); + const parts = text.split(",").map((piece): Part => { + const [base, stepText] = piece.split("/"); + const step = stepText === undefined ? 1 : Number(stepText); + if (!Number.isInteger(step) || step < 1) throw new Error(`“/${stepText}” is not a valid repetition`); + let from: number, to: number; + if (base === "*") [from, to] = [r.min, r.max]; + else if (base.includes("..")) { + const [a, b] = base.split(".."); + [from, to] = [number(a, r), number(b, r)]; + if (from > to) throw new Error(`Range ${base} goes backwards`); + } else { + from = number(base, r); + to = stepText === undefined ? from : r.max; + } + const kind = stepText !== undefined ? "step" : base === "*" ? "any" : from === to ? "value" : "range"; + return { kind, from, to, step }; + }); + return finish(parts, r, false); +} + +/** `~07/1` → the 7th last day and every day after it: offsets 6…0 from the end. */ +function lastDays(text: string): number[] { + const r = { name: "day from the end", min: 1, max: 31 }; + const out = new Set(); + for (const piece of text.split(",")) { + const [base, stepText] = piece.split("/"); + const step = stepText === undefined ? 1 : Number(stepText); + if (!Number.isInteger(step) || step < 1) throw new Error(`“/${stepText}” is not a valid repetition`); + const [a, b] = base.split(".."); + const from = number(a, r); + // Towards the end of the month: ~07/2 is the 7th, 5th, 3rd and last day. + const to = b !== undefined ? number(b, r) : stepText !== undefined ? 1 : from; + const [hi, lo] = from >= to ? [from, to] : [to, from]; + for (let n = hi; n >= lo; n -= step) out.add(n - 1); + } + return [...out].sort((x, y) => x - y); +} + +const looksLikeZone = (t: string) => /^(UTC|GMT|[A-Z][A-Za-z_]+\/[A-Za-z_/+-]+|[A-Z]{3,4})$/.test(t); + +/** A systemd `OnCalendar=` value: `Mon..Fri *-*-* 09:00:00`, `daily`, `*-*~01 12:00`… */ +export function parseSystemd(input: string): Schedule { + const source = input.trim(); + if (!source) throw new Error("Type a calendar event, like Mon..Fri 09:00"); + const tokens = source.split(/\s+/); + let timezone: string | undefined; + if ( + tokens.length > 1 && looksLikeZone(tokens[tokens.length - 1]) && + !/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(tokens[tokens.length - 1]) + ) { + timezone = tokens.pop(); + } + const shorthand = SYSTEMD_SHORTHANDS[tokens.join(" ").toLowerCase()]; + if (shorthand) return { ...parseSystemd(shorthand), source, macro: tokens.join(" ").toLowerCase(), timezone }; + const s: Schedule = { ...blank(), kind: "systemd", source, hasSeconds: true, timezone }; + let i = 0; + if (/^[a-z]/i.test(tokens[0])) s.weekday = systemdWeekdays(tokens[i++]); + let date: string | undefined, time: string | undefined; + for (const t of tokens.slice(i)) { + if (t.includes(":")) { + if (time) throw new Error(`Two times in “${source}”`); + time = t; + } else if (/[-~]/.test(t)) { + if (date || time) throw new Error(`Unexpected “${t}”: the date comes before the time`); + date = t; + } else throw new Error(`“${t}” is neither a date (*-*-01) nor a time (09:00)`); + } + if (date) { + const m = date.match(/^(?:([^-~]+)-)?([^-~]+)([-~])([^-~]+)$/); + if (!m) throw new Error(`“${date}” is not a date like *-*-01 or 2026-10-28`); + const [, y, mon, sep, d] = m; + if (y !== undefined && y !== "*") s.year = parseSystemdComponent(y, R.year); + s.month = parseSystemdComponent(mon, R.month); + if (sep === "~") { + // `~01` is the last day, `~07/1` the last seven days. + s.day = { parts: [], values: [], star: false }; + s.lastDays = lastDays(d); + } else s.day = parseSystemdComponent(d, R.day); + } + if (time) { + const pieces = time.split(":"); + if (pieces.length < 2 || pieces.length > 3) throw new Error(`“${time}” is not a time like 09:00 or 09:00:30`); + s.hour = parseSystemdComponent(pieces[0], R.hour); + s.minute = parseSystemdComponent(pieces[1], R.minute); + // Fractions of seconds can't be scheduled more finely than seconds here. + s.second = pieces[2] === undefined ? single(0) : parseSystemdComponent(pieces[2].replace(/\.\d+$/, ""), R.second); + } else { + s.hour = single(0); + s.minute = single(0); + s.second = single(0); + } + return s; +} + +/** Cron when it looks like cron, else systemd. */ +export function parseAny(input: string): Schedule { + const text = input.trim(); + const cronLike = text.startsWith("@") || /^[\d*?,\/\-a-z#LW]+(\s+[\d*?,\/\-a-z#LW]+){4,6}$/i.test(text); + if (cronLike && !/\.\.|:|~/.test(text)) { + try { + return parseCron(text); + } catch (e) { + if (text.startsWith("@") || !/[a-z]{4,}/i.test(text)) throw e; + } + } + try { + return parseSystemd(text); + } catch (e) { + // Only cron characters and no date or time: the cron error is the useful one. + if (/^[\d*?,\/\s]+$/.test(text) || cronLike) return parseCron(text); + throw e; + } +} + +// --------------------------------------------------------------------------- +// Next runs + +const daysIn = (year: number, month0: number) => new Date(year, month0 + 1, 0).getDate(); + +export function dayMatches(s: Schedule, date: Date): boolean { + const d = date.getDate(), wd = date.getDay(); + const dim = daysIn(date.getFullYear(), date.getMonth()); + const domRestricted = !s.day.star || s.lastDays.length > 0; + const dowRestricted = !s.weekday.star || s.nth.length > 0 || s.lastWeekday.length > 0; + const dom = s.day.values.includes(d) || s.lastDays.includes(dim - d); + const dow = s.weekday.values.includes(wd) || + s.nth.some((x) => x.weekday === wd && Math.ceil(d / 7) === x.n) || + s.lastWeekday.some((w) => w === wd && d + 7 > dim); + if (domRestricted && dowRestricted) return s.kind === "cron" ? dom || dow : dom && dow; + if (domRestricted) return dom; + if (dowRestricted) return dow; + return true; +} + +/** The first time strictly after `after` matching the schedule, in local time. */ +export function nextRun(s: Schedule, after: Date): Date | undefined { + if (s.reboot) return undefined; + let t = new Date(after.getTime()); + t.setMilliseconds(0); + t = new Date(t.getTime() + 1000); + const limit = after.getFullYear() + 30; + for (let i = 0; i < 200_000; i++) { + const y = t.getFullYear(), mo = t.getMonth(), d = t.getDate(), h = t.getHours(), mi = t.getMinutes(); + const prev = t.getTime(); + if (y > limit || (s.year && y > Math.max(...s.year.values))) return undefined; + if (s.year && !s.year.values.includes(y)) t = new Date(y + 1, 0, 1); + else if (!s.month.values.includes(mo + 1)) t = new Date(y, mo + 1, 1); + else if (!dayMatches(s, t)) t = new Date(y, mo, d + 1); + else if (!s.hour.values.includes(h)) t = new Date(y, mo, d, h + 1); + else if (!s.minute.values.includes(mi)) t = new Date(y, mo, d, h, mi + 1); + else if (!s.second.values.includes(t.getSeconds())) t = new Date(y, mo, d, h, mi, t.getSeconds() + 1); + else return t; + // A repeated hour when clocks go back: never move backwards. + if (t.getTime() <= prev) t = new Date(prev + 1000); + } + return undefined; +} + +export function nextRuns(s: Schedule, from: Date, count = 10): Date[] { + const out: Date[] = []; + let t = from; + while (out.length < count) { + const n = nextRun(s, t); + if (!n) break; + out.push(n); + t = n; + } + return out; +} + +// --------------------------------------------------------------------------- +// English + +const pad = (n: number) => String(n).padStart(2, "0"); +const clock = (h: number, m: number, sec?: number) => `${pad(h)}:${pad(m)}${sec ? `:${pad(sec)}` : ""}`; + +export function joinWords(words: string[], last = "and"): string { + if (words.length <= 1) return words[0] ?? ""; + return `${words.slice(0, -1).join(", ")} ${last} ${words[words.length - 1]}`; +} + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th"; + return `${n}${s}`; +} + +const isValues = (f: Field) => !f.star && f.parts.every((p) => p.kind === "value"); +const isSingle = (f: Field) => isValues(f) && f.values.length === 1; +const onlyStep = (f: Field, r: Range) => + f.parts.length === 1 && f.parts[0].kind === "step" && f.parts[0].from === r.min && + f.parts[0].to >= (r === R.weekday ? 6 : r.max); + +/** `1-5` → "1 through 5"; lists joined. `name` turns numbers into words. */ +function describeParts(f: Field, name: (n: number) => string, unit: string): string { + return joinWords(f.parts.map((p) => { + if (p.kind === "value") return name(p.from); + if (p.kind === "range") return `${name(p.from)} through ${name(p.to)}`; + if (p.kind === "step") { + const every = `every ${ordinal(p.step)} ${unit}`; + return `${every} from ${name(p.from)} through ${name(p.to)}`; + } + return `every ${unit}`; + })); +} + +function describeTime(s: Schedule): string { + const { second: sec, minute: min, hour } = s; + const secs = s.hasSeconds && !(isSingle(sec) && sec.values[0] === 0); + // At 09:30, at 09:00 and 17:00. + if (isValues(min) && isValues(hour) && (!secs || isSingle(sec)) && min.values.length * hour.values.length <= 6) { + const times = hour.values.flatMap((h) => min.values.map((m) => clock(h, m, secs ? sec.values[0] : undefined))); + return `At ${joinWords(times)}`; + } + let secPart = ""; + if (secs) { + if (sec.star) secPart = "Every second"; + else if (onlyStep(sec, R.second)) secPart = `Every ${sec.parts[0].step} seconds`; + else secPart = `At second ${describeParts(sec, String, "second")}`; + } + let minPart: string; + if (min.star) minPart = secs ? "" : "every minute"; + else if (onlyStep(min, R.minute)) minPart = `every ${min.parts[0].step} minutes`; + else if (isValues(min)) minPart = `at minute${min.values.length > 1 ? "s" : ""} ${joinWords(min.values.map(String))}`; + else minPart = `at minutes ${describeParts(min, String, "minute")}`; + let hourPart: string; + if (hour.star) hourPart = min.star || onlyStep(min, R.minute) ? "" : "past every hour"; + else if (hour.parts.length === 1 && hour.parts[0].kind === "step") { + const p = hour.parts[0]; + const window = onlyStep(hour, R.hour) ? "" : ` from ${clock(p.from, 0)} through ${clock(p.to, 59)}`; + hourPart = min.star || onlyStep(min, R.minute) + ? `every ${p.step} hours${window}` + : `past every ${ordinal(p.step)} hour${window}`; + } else if (hour.parts.length === 1 && hour.parts[0].kind === "range") { + hourPart = `between ${clock(hour.parts[0].from, 0)} and ${clock(hour.parts[0].to, 59)}`; + } else if (isSingle(hour)) hourPart = `between ${clock(hour.values[0], 0)} and ${clock(hour.values[0], 59)}`; + else if (isValues(hour)) hourPart = `during hours ${joinWords(hour.values.map(pad))}`; + else hourPart = `during hours ${describeParts(hour, pad, "hour")}`; + const words = [secPart, minPart, hourPart].filter(Boolean).join(minPart && secPart ? ", " : " "); + return words[0].toUpperCase() + words.slice(1); +} + +function describeDays(s: Schedule): string { + const dom: string[] = []; + if (s.lastDays.length) { + const sorted = [...s.lastDays].sort((a, b) => a - b); + if (sorted.length === 1 && sorted[0] === 0) dom.push("on the last day of the month"); + else if (sorted.length === 1) { + dom.push(`${sorted[0]} day${sorted[0] > 1 ? "s" : ""} before the last day of the month`); + } else if (sorted.every((n, i) => n === i)) dom.push(`on the last ${sorted.length} days of the month`); + else dom.push(`on days ${joinWords(sorted.map((n) => n === 0 ? "last" : `last − ${n}`))} of the month`); + } + if (!s.day.star && s.day.parts.length) { + const f = s.day; + if (onlyStep(f, R.day)) dom.push(`every ${f.parts[0].step} days`); + else if (isValues(f)) { + dom.push(`on day${f.values.length > 1 ? "s" : ""} ${joinWords(f.values.map(String))} of the month`); + } else dom.push(`on days ${describeParts(f, String, "day")} of the month`); + } + const dow: string[] = []; + for (const x of s.nth) { + dow.push(`on the ${["", "first", "second", "third", "fourth", "fifth"][x.n]} ${DAY_NAMES[x.weekday]} of the month`); + } + for (const w of s.lastWeekday) dow.push(`on the last ${DAY_NAMES[w]} of the month`); + if (!s.weekday.star && s.weekday.parts.length) { + const f = s.weekday; + const name = (n: number) => DAY_NAMES[n % 7]; + if (f.values.join() === "1,2,3,4,5") dow.push("on weekdays"); + else if (f.values.join() === "0,6") dow.push("on weekends"); + else if (isValues(f)) dow.push(`on ${joinWords(f.values.map(name))}`); + else dow.push(`on ${describeParts(f, name, "day of the week")}`); + } + if (dom.length && dow.length) { + if (s.kind === "cron") return `${joinWords(dom)}, or ${joinWords(dow, "or")}`; + // systemd: both must match, e.g. the last seven days that are a Friday. + if (s.lastDays.length === 7 && s.lastDays.every((n) => n < 7) && isSingle(s.weekday)) { + return `on the last ${DAY_NAMES[s.weekday.values[0]]} of the month`; + } + const nth = ["01..07", "08..14", "15..21", "22..28"].indexOf(systemdComponent(s.day, R.day)); + if (nth >= 0 && !s.lastDays.length && isSingle(s.weekday)) { + return `on the ${["first", "second", "third", "fourth"][nth]} ${DAY_NAMES[s.weekday.values[0]]} of the month`; + } + return `${joinWords(dom)}, if it is ${joinWords(dow.map((d) => d.replace(/^on /, "a ")), "or")}`; + } + return joinWords([...dom, ...dow], dom.length ? "and" : "or"); +} + +function describeMonths(s: Schedule): string { + const f = s.month; + if (f.star) return ""; + if (onlyStep(f, R.month)) return `every ${f.parts[0].step} months`; + return `in ${describeParts(f, (n) => MONTH_NAMES[n - 1], "month")}`; +} + +/** "At 09:00, on weekdays". */ +export function explain(s: Schedule): string { + if (s.reboot) return "Once, when the system starts"; + const parts = [describeTime(s), describeDays(s), describeMonths(s)]; + if (s.year) parts.push(`in ${describeParts(s.year, String, "year")}`); + return parts.filter(Boolean).join(", "); +} + +/** One line per field, for the side panel. */ +export function fieldLines(s: Schedule): [string, string][] { + const text = (f: Field, r: Range, name: (n: number) => string = String) => + f.star ? "every" : f.parts.length ? describeParts(f, name, r.name) : ""; + const lines: [string, string][] = []; + if (s.hasSeconds) lines.push(["Second", text(s.second, R.second)]); + lines.push(["Minute", text(s.minute, R.minute)], ["Hour", text(s.hour, R.hour)]); + const days = describeDays({ ...s, weekday: any(R.weekday), nth: [], lastWeekday: [] }); + lines.push(["Day of month", days ? days.replace(/^on /, "") : "every"]); + lines.push(["Month", text(s.month, R.month, (n) => MONTH_NAMES[n - 1])]); + const dows = describeDays({ ...s, day: any(R.day), lastDays: [] }); + lines.push(["Day of week", dows ? dows.replace(/^on /, "") : "every"]); + if (s.year) lines.push(["Year", text(s.year, R.year)]); + return lines; +} + +// --------------------------------------------------------------------------- +// Conversion + +function systemdComponent(f: Field, r: Range): string { + if (f.star) return "*"; + return f.parts.map((p) => { + const v = (n: number) => r === R.year ? String(n) : pad(n); + if (p.kind === "value") return v(p.from); + if (p.kind === "range") return `${v(p.from)}..${v(p.to)}`; + if (p.kind === "any") return "*"; + return p.to >= r.max ? `${v(p.from)}/${p.step}` : `${v(p.from)}..${v(p.to)}/${p.step}`; + }).join(","); +} + +function systemdWeekdayList(values: number[]): string { + // Monday first; runs of 3+ days become ranges. + const order = [1, 2, 3, 4, 5, 6, 0].filter((d) => values.includes(d)); + const out: string[] = []; + let i = 0; + while (i < order.length) { + let j = i; + while (j + 1 < order.length && (order[j + 1] - order[j] + 7) % 7 === 1) j++; + if (j - i >= 2) out.push(`${SYSTEMD_DAYS[order[i]]}..${SYSTEMD_DAYS[order[j]]}`); + else for (let k = i; k <= j; k++) out.push(SYSTEMD_DAYS[order[k]]); + i = j + 1; + } + return out.join(","); +} + +/** Offsets from the end → `~01`, `~07/1` (the last seven days) or `~01,03`. */ +function lastDaysComponent(offsets: number[]): string { + const sorted = [...offsets].sort((a, b) => a - b); + if (sorted.length > 1 && sorted.every((n, i) => n === i)) return `~${pad(sorted.length)}/1`; + return `~${sorted.map((n) => pad(n + 1)).join(",")}`; +} + +const NTH_DAYS = ["", "01..07", "08..14", "15..21", "22..28", "29..31"]; + +/** The `OnCalendar=` lines matching a cron schedule (several when cron's day-of-month OR day-of-week can't be one line). */ +export function toSystemd(s: Schedule): string[] { + if (s.reboot) throw new Error("@reboot has no calendar equivalent: use OnBootSec= in the timer instead"); + const time = `${systemdComponent(s.hour, R.hour)}:${systemdComponent(s.minute, R.minute)}:${ + systemdComponent(s.second, R.second) + }`; + const year = s.year ? systemdComponent(s.year, R.year) : "*"; + const month = systemdComponent(s.month, R.month); + const date = (day: string) => `${year}-${month}${day.startsWith("~") ? day : `-${day}`}`; + const lines: string[] = []; + const domRestricted = !s.day.star || s.lastDays.length > 0; + const plainDow = !s.weekday.star && s.weekday.values.length > 0; + const dowRestricted = plainDow || s.nth.length > 0 || s.lastWeekday.length > 0; + const dayFields: string[] = []; + if (!s.day.star && s.day.parts.length) dayFields.push(systemdComponent(s.day, R.day)); + if (s.lastDays.length) dayFields.push(lastDaysComponent(s.lastDays)); + if (s.kind === "systemd" || !(domRestricted && dowRestricted)) { + const dow = plainDow ? `${systemdWeekdayList(s.weekday.values)} ` : ""; + if (!dowRestricted || plainDow) { + if (!dayFields.length) lines.push(`${dow}${date("*")} ${time}`); + for (const d of dayFields) lines.push(`${dow}${date(d)} ${time}`); + } + } else { + for (const d of dayFields) lines.push(`${date(d)} ${time}`); + if (plainDow) lines.push(`${systemdWeekdayList(s.weekday.values)} ${date("*")} ${time}`); + } + for (const x of s.nth) lines.push(`${SYSTEMD_DAYS[x.weekday]} ${date(NTH_DAYS[x.n])} ${time}`); + for (const w of s.lastWeekday) lines.push(`${SYSTEMD_DAYS[w]} ${year}-${month}~07/1 ${time}`); + // `*-*-* 00:00:00` reads better as the shorthand systemd prints. + return lines.map((l) => l === "*-*-* 00:00:00" ? "daily" : l === "*-*-* *:00:00" ? "hourly" : l); +} + +function cronComponent(f: Field, r: Range): string { + if (f.star) return "*"; + const max = r === R.weekday ? 6 : r.max; + return f.parts.map((p) => { + if (p.kind === "value") return String(p.from); + if (p.kind === "range") return `${p.from}-${p.to}`; + if (p.kind === "any") return "*"; + if (p.from === r.min && p.to >= max) return `*/${p.step}`; + return `${p.from}-${Math.min(p.to, max)}/${p.step}`; + }).join(","); +} + +/** The cron line for a systemd schedule; throws when cron can't say it. */ +export function toCron(s: Schedule): string { + if (s.kind === "cron") return s.source; + if (s.year) throw new Error("Cron has no year field: this calendar event names specific years"); + const sec = cronComponent(s.second, R.second); + let dom = s.day.star ? "*" : cronComponent(s.day, R.day); + let dow = s.weekday.star ? "*" : s.weekday.values.join(","); + const range = s.weekday.parts.length === 1 && s.weekday.parts[0].kind === "range" ? s.weekday.parts[0] : undefined; + if (range && range.to <= 7) dow = `${range.from}-${range.to}`; + const last = [...s.lastDays].sort((a, b) => a - b); + if (last.length) { + if (!s.day.star && s.day.parts.length) throw new Error("Cron can't combine days from the end with other days"); + if (last.length === 7 && last[6] === 6 && isSingle(s.weekday)) { + [dom, dow] = ["*", `${s.weekday.values[0]}L`]; + } else if (last.length === 1) dom = last[0] ? `L-${last[0]}` : "L"; + else throw new Error("Cron can only say the last day of the month (L)"); + } else if (!s.day.star && !s.weekday.star) { + // Cron reads both as OR; only "the Nth weekday" can be said. + const n = NTH_DAYS.indexOf(systemdComponent(s.day, R.day)); + if (n > 0 && isSingle(s.weekday)) [dom, dow] = ["*", `${s.weekday.values[0]}#${n}`]; + else throw new Error("Cron runs when the day of month OR the weekday matches, systemd when both do"); + } + const fields = [ + cronComponent(s.minute, R.minute), + cronComponent(s.hour, R.hour), + dom, + cronComponent(s.month, R.month), + dow, + ]; + return sec === "0" ? fields.join(" ") : [sec, ...fields].join(" "); +} + +// --------------------------------------------------------------------------- +// Presentation + +export interface Preset { + cron: string; + title: string; +} + +export const PRESETS: Preset[] = [ + { cron: "* * * * *", title: "Every minute" }, + { cron: "*/5 * * * *", title: "Every 5 minutes" }, + { cron: "*/15 * * * *", title: "Every 15 minutes" }, + { cron: "0 * * * *", title: "Every hour" }, + { cron: "0 */6 * * *", title: "Every 6 hours" }, + { cron: "0 0 * * *", title: "Every day at midnight" }, + { cron: "0 9 * * *", title: "Every day at 09:00" }, + { cron: "0 9 * * 1-5", title: "Weekdays at 09:00" }, + { cron: "*/30 9-17 * * 1-5", title: "Every 30 minutes during office hours" }, + { cron: "0 0 * * 0", title: "Every Sunday at midnight" }, + { cron: "0 3 * * 6", title: "Every Saturday at 03:00" }, + { cron: "0 0 1 * *", title: "First day of the month" }, + { cron: "0 0 L * *", title: "Last day of the month" }, + { cron: "0 10 * * 1#1", title: "First Monday of the month at 10:00" }, + { cron: "0 0 1 */3 *", title: "Every quarter" }, + { cron: "0 0 1 1 *", title: "Every year on January 1st" }, + { cron: "@reboot", title: "At startup" }, +]; + +/** "in 5 min", "in 3 h", "in 2 days". */ +export function until(date: Date, now: Date): string { + const s = Math.round((date.getTime() - now.getTime()) / 1000); + if (s < 60) return `in ${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `in ${m} min`; + const h = Math.round(m / 60); + if (h < 36) return `in ${h} h`; + const d = Math.round(h / 24); + if (d < 60) return `in ${d} days`; + const mo = Math.round(d / 30); + if (mo < 24) return `in ${mo} months`; + return `in ${Math.round(d / 365)} years`; +} + +/** "Wed 28 Oct 2026, 09:00" in local time. */ +export function formatRun(d: Date, seconds = false): string { + const day = `${DAY_NAMES[d.getDay()].slice(0, 3)} ${d.getDate()} ${ + MONTH_NAMES[d.getMonth()].slice(0, 3) + } ${d.getFullYear()}`; + return `${day}, ${clock(d.getHours(), d.getMinutes(), seconds ? d.getSeconds() : undefined)}`; +} + +/** A timer unit to paste into `~/.config/systemd/user/.timer`. */ +export function timerUnit(lines: string[], description: string): string { + return [ + "[Unit]", + `Description=${description}`, + "", + "[Timer]", + ...lines.map((l) => `OnCalendar=${l}`), + "Persistent=true", + "", + "[Install]", + "WantedBy=timers.target", + ].join("\n"); +} diff --git a/extensions/cron/lib_test.ts b/extensions/cron/lib_test.ts new file mode 100644 index 0000000..5f904c5 --- /dev/null +++ b/extensions/cron/lib_test.ts @@ -0,0 +1,271 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + dayMatches, + explain, + fieldLines, + formatRun, + joinWords, + nextRun, + nextRuns, + ordinal, + parseAny, + parseCron, + parseCronField, + parseSystemd, + timerUnit, + toCron, + toSystemd, + until, +} from "./lib.ts"; + +// Local time throughout: June and January, away from daylight saving changes. +const at = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s); +const runs = (expr: string, from: Date, n = 3) => + nextRuns(parseAny(expr), from, n).map((d) => formatRun(d, parseAny(expr).hasSeconds)); + +Deno.test("cron fields: values, ranges, steps, lists and names", () => { + const r = { name: "minute", min: 0, max: 59 }; + assertEquals(parseCronField("*/15", r).values, [0, 15, 30, 45]); + assertEquals(parseCronField("10-20/5", r).values, [10, 15, 20]); + assertEquals(parseCronField("5/20", r).values, [5, 25, 45]); + assertEquals(parseCronField("1,2,3-5", r).values, [1, 2, 3, 4, 5]); + const s = parseCron("0 9 * JAN-MAR MON-FRI"); + assertEquals(s.month.values, [1, 2, 3]); + assertEquals(s.weekday.values, [1, 2, 3, 4, 5]); + assertEquals(parseCron("0 0 * * 7").weekday.values, [0]); + assertEquals(parseCron("0 0 * * FRI-SUN").weekday.values, [0, 5, 6]); + assertEquals(parseCron("0 0 ? * *").day.star, true); +}); + +Deno.test("cron shapes: 5, 6 and 7 fields, macros", () => { + assertEquals(parseCron("*/5 * * * *").hasSeconds, false); + const six = parseCron("*/10 * * * * *"); + assertEquals([six.hasSeconds, six.second.values.length], [true, 6]); + assertEquals(parseCron("0 0 12 1 1 * 2027").year?.values, [2027]); + assertEquals(parseCron("@daily").minute.values, [0]); + assertEquals(parseCron("@weekly").weekday.values, [0]); + assertEquals(parseCron("@reboot").reboot, true); + assertEquals(parseCron("0 0 L * *").lastDays, [0]); + assertEquals(parseCron("0 0 L-2 * *").lastDays, [2]); + assertEquals(parseCron("0 10 * * 1#2").nth, [{ weekday: 1, n: 2 }]); + assertEquals(parseCron("0 18 * * FRIL").lastWeekday, [5]); +}); + +Deno.test("cron errors say what is wrong", () => { + assertThrows(() => parseCron("* * *"), Error, "5 fields, this one has 3"); + assertThrows(() => parseCron("61 * * * *"), Error, "Minute 61 is out of range (0–59)"); + assertThrows(() => parseCron("* 24 * * *"), Error, "Hour 24 is out of range"); + assertThrows(() => parseCron("5-2 * * * *"), Error, "goes backwards"); + assertThrows(() => parseCron("*/0 * * * *"), Error, "not a valid step"); + assertThrows(() => parseCron("* * * * MON-FOO"), Error, "“FOO” is not a valid day of week"); + assertThrows(() => parseCron("@sometimes"), Error, "Unknown shorthand"); + assertThrows(() => parseCron("1,,2 * * * *"), Error, "Empty value"); + assertThrows(() => parseAny("* * *"), Error, "5 fields"); +}); + +Deno.test("English explanations", () => { + const cases: [string, string][] = [ + ["* * * * *", "Every minute"], + ["*/5 * * * *", "Every 5 minutes"], + ["0 * * * *", "At minute 0 past every hour"], + ["0,30 * * * *", "At minutes 0 and 30 past every hour"], + ["0 */6 * * *", "At minute 0 past every 6th hour"], + ["23 0-20/2 * * *", "At minute 23 past every 2nd hour from 00:00 through 20:59"], + ["0 9 * * 1-5", "At 09:00, on weekdays"], + ["0 0 * * SAT,SUN", "At 00:00, on weekends"], + ["0 0,12 * * *", "At 00:00 and 12:00"], + ["*/30 9-17 * * 1-5", "Every 30 minutes between 09:00 and 17:59, on weekdays"], + ["* 9 * * *", "Every minute between 09:00 and 09:59"], + ["15 14 1 * *", "At 14:15, on day 1 of the month"], + ["0 0 1 */3 *", "At 00:00, on day 1 of the month, every 3 months"], + ["5 0 * 8 *", "At 00:05, in August"], + ["0 4 8-14 * *", "At 04:00, on days 8 through 14 of the month"], + ["0 0 1,15 * 5", "At 00:00, on days 1 and 15 of the month, or on Friday"], + ["0 0 L * *", "At 00:00, on the last day of the month"], + ["0 10 * * 1#1", "At 10:00, on the first Monday of the month"], + ["0 18 * * 5L", "At 18:00, on the last Friday of the month"], + ["0 9 * * 1,3,5", "At 09:00, on Monday, Wednesday and Friday"], + ["*/10 * * * * *", "Every 10 seconds"], + ["30 0 9 * * *", "At 09:00:30"], + ["@weekly", "At 00:00, on Sunday"], + ["@reboot", "Once, when the system starts"], + ]; + for (const [expr, english] of cases) assertEquals(explain(parseCron(expr)), english, expr); +}); + +Deno.test("field lines for the side panel", () => { + assertEquals(fieldLines(parseCron("0 9 * * 1-5")), [ + ["Minute", "0"], + ["Hour", "9"], + ["Day of month", "every"], + ["Month", "every"], + ["Day of week", "weekdays"], + ]); +}); + +Deno.test("next runs in local time", () => { + const wed = at(2026, 6, 10, 10, 0); // Wednesday 10 June 2026, 10:00 + assertEquals(runs("*/5 * * * *", wed), [ + "Wed 10 Jun 2026, 10:05", + "Wed 10 Jun 2026, 10:10", + "Wed 10 Jun 2026, 10:15", + ]); + assertEquals(runs("0 9 * * 1-5", wed), [ + "Thu 11 Jun 2026, 09:00", + "Fri 12 Jun 2026, 09:00", + "Mon 15 Jun 2026, 09:00", + ]); + assertEquals(runs("0 0 L * *", wed), ["Tue 30 Jun 2026, 00:00", "Fri 31 Jul 2026, 00:00", "Mon 31 Aug 2026, 00:00"]); + assertEquals(runs("0 0 L-1 2 *", wed, 2), ["Sat 27 Feb 2027, 00:00", "Mon 28 Feb 2028, 00:00"]); + assertEquals(runs("0 10 * * 1#1", wed), ["Mon 6 Jul 2026, 10:00", "Mon 3 Aug 2026, 10:00", "Mon 7 Sep 2026, 10:00"]); + assertEquals(runs("0 18 * * 5L", wed), [ + "Fri 26 Jun 2026, 18:00", + "Fri 31 Jul 2026, 18:00", + "Fri 28 Aug 2026, 18:00", + ]); + // Day of month OR day of week, as cron does. + assertEquals(runs("0 0 1,15 * 5", wed), [ + "Fri 12 Jun 2026, 00:00", + "Mon 15 Jun 2026, 00:00", + "Fri 19 Jun 2026, 00:00", + ]); + assertEquals(runs("*/20 * * * * *", at(2026, 6, 10, 10, 0, 30)), [ + "Wed 10 Jun 2026, 10:00:40", + "Wed 10 Jun 2026, 10:01", + "Wed 10 Jun 2026, 10:01:20", + ]); + // Leap years. + assertEquals(runs("0 0 29 2 *", wed, 2), ["Tue 29 Feb 2028, 00:00", "Sun 29 Feb 2032, 00:00"]); + // Strictly after: a run at the start time itself is not the next one. + assertEquals(formatRun(nextRun(parseCron("0 10 * * *"), at(2026, 6, 10, 10, 0))!), "Thu 11 Jun 2026, 10:00"); + assertEquals(nextRun(parseCron("@reboot"), wed), undefined); + assertEquals(nextRuns(parseCron("0 0 0 1 1 * 2020"), wed), []); + assertEquals(nextRuns(parseCron("0 0 31 2 *"), wed), []); +}); + +Deno.test("day matching follows cron and systemd rules", () => { + const fri12 = at(2026, 6, 12), mon15 = at(2026, 6, 15); + const cron = parseCron("0 0 15 * 5"); + assertEquals([dayMatches(cron, fri12), dayMatches(cron, mon15)], [true, true]); + const systemd = parseSystemd("Fri *-*-15 00:00"); + assertEquals([dayMatches(systemd, fri12), dayMatches(systemd, mon15)], [false, false]); + assertEquals(dayMatches(systemd, at(2026, 5, 15)), true); // Friday 15 May 2026 +}); + +Deno.test("systemd calendar events", () => { + const s = parseSystemd("Mon..Fri *-*-* 09:00:00"); + assertEquals([s.weekday.values, s.hour.values, s.minute.values, s.second.values], [[1, 2, 3, 4, 5], [9], [0], [0]]); + assertEquals(parseSystemd("Sat,Sun 10:30").weekday.values, [0, 6]); + assertEquals(parseSystemd("Sat..Mon 10:30").weekday.values, [0, 1, 6]); + assertEquals(parseSystemd("*:0/15").minute.values, [0, 15, 30, 45]); + assertEquals(parseSystemd("*-*~01").lastDays, [0]); + assertEquals(parseSystemd("Fri *-*~07/1 18:00").lastDays, [0, 1, 2, 3, 4, 5, 6]); + assertEquals(parseSystemd("*-02~03..01").lastDays, [0, 1, 2]); + assertEquals(parseSystemd("10-28 12:00").month.values, [10]); + assertEquals(parseSystemd("2027-01-01").year?.values, [2027]); + assertEquals(parseSystemd("*-*-* 12:00:00.5").second.values, [0]); + assertEquals(parseSystemd("Sat *-*-* 10:30 Europe/Paris").timezone, "Europe/Paris"); + assertEquals(parseSystemd("weekly").macro, "weekly"); + assertEquals(parseSystemd("quarterly").month.values, [1, 4, 7, 10]); + assertThrows(() => parseSystemd("Funday 10:00"), Error, "not a day of the week"); + assertThrows(() => parseSystemd("12:00 *-*-01"), Error, "the date comes before the time"); + assertThrows(() => parseSystemd("*-13-01"), Error, "Month 13 is out of range"); + assertEquals(explain(parseSystemd("Fri *-*~07/1 18:00")), "At 18:00, on the last Friday of the month"); + assertEquals(explain(parseSystemd("Mon *-*-01..07 10:00")), "At 10:00, on the first Monday of the month"); + assertEquals(explain(parseSystemd("Fri *-*-13")), "At 00:00, on day 13 of the month, if it is a Friday"); + assertEquals(runs("Mon..Fri 09:00", at(2026, 1, 9, 12)), [ + "Mon 12 Jan 2026, 09:00", + "Tue 13 Jan 2026, 09:00", + "Wed 14 Jan 2026, 09:00", + ]); + assertEquals(runs("Fri *-*-13", at(2026, 1, 1), 2), ["Fri 13 Feb 2026, 00:00", "Fri 13 Mar 2026, 00:00"]); +}); + +Deno.test("parseAny tells cron and systemd apart", () => { + assertEquals(parseAny("*/5 * * * *").kind, "cron"); + assertEquals(parseAny("0 9 * * MON-FRI").kind, "cron"); + assertEquals(parseAny("@daily").kind, "cron"); + assertEquals(parseAny("daily").kind, "systemd"); + assertEquals(parseAny("Mon..Fri 09:00").kind, "systemd"); + assertEquals(parseAny("*-*-01 00:00").kind, "systemd"); + assertEquals(parseAny("12:30").kind, "systemd"); +}); + +Deno.test("cron → systemd OnCalendar", () => { + const cases: [string, string[]][] = [ + ["*/5 * * * *", ["*-*-* *:00/5:00"]], + ["0 * * * *", ["hourly"]], + ["0 0 * * *", ["daily"]], + ["0 9 * * 1-5", ["Mon..Fri *-*-* 09:00:00"]], + ["0 9 * * 1,3,5", ["Mon,Wed,Fri *-*-* 09:00:00"]], + ["0 0 * * SAT,SUN", ["Sat,Sun *-*-* 00:00:00"]], + ["*/30 9-17 * * 1-5", ["Mon..Fri *-*-* 09..17:00/30:00"]], + ["0 0 1 */3 *", ["*-01/3-01 00:00:00"]], + ["0 4 8-14 * *", ["*-*-08..14 04:00:00"]], + ["0 0 L * *", ["*-*~01 00:00:00"]], + ["0 0 L-2 * *", ["*-*~03 00:00:00"]], + ["0 10 * * 1#1", ["Mon *-*-01..07 10:00:00"]], + ["0 18 * * 5L", ["Fri *-*~07/1 18:00:00"]], + ["0 0 1,15 * 5", ["*-*-01,15 00:00:00", "Fri *-*-* 00:00:00"]], + ["@weekly", ["Sun *-*-* 00:00:00"]], + ["*/10 * * * * *", ["*-*-* *:*:00/10"]], + ["0 0 12 1 1 * 2027", ["2027-01-01 12:00:00"]], + ]; + for (const [expr, lines] of cases) assertEquals(toSystemd(parseCron(expr)), lines, expr); + assertThrows(() => toSystemd(parseCron("@reboot")), Error, "OnBootSec="); +}); + +Deno.test("systemd → cron", () => { + const cases: [string, string][] = [ + ["Mon..Fri 09:00", "0 9 * * 1-5"], + ["daily", "0 0 * * *"], + ["weekly", "0 0 * * 1"], + ["monthly", "0 0 1 * *"], + ["quarterly", "0 0 1 1,4,7,10 *"], + ["*:0/15", "*/15 * * * *"], + ["*-*-* 08..18/2:30", "30 8-18/2 * * *"], + ["Sat,Sun 10:30", "30 10 * * 0,6"], + ["*-*~01 12:00", "0 12 L * *"], + ["Fri *-*~07/1 18:00", "0 18 * * 5L"], + ["Mon *-*-08..14 10:00", "0 10 * * 1#2"], + ["*-*-* *:*:00/10", "*/10 * * * * *"], + ["*-*-* 12:00:30", "30 0 12 * * *"], + ]; + for (const [expr, cron] of cases) assertEquals(toCron(parseSystemd(expr)), cron, expr); + assertThrows(() => toCron(parseSystemd("2027-01-01")), Error, "no year field"); + assertThrows(() => toCron(parseSystemd("Fri *-*-13")), Error, "OR"); + // Round trips agree on the runs. + for (const expr of ["Mon..Fri 09:00", "*-*~01 12:00", "Fri *-*~07/1 18:00", "*:0/15"]) { + const from = at(2026, 6, 10, 10); + assertEquals(runs(toCron(parseSystemd(expr)), from, 5), runs(expr, from, 5), expr); + } +}); + +Deno.test("words, dates and the timer unit", () => { + assertEquals([joinWords(["a"]), joinWords(["a", "b"]), joinWords(["a", "b", "c"], "or")], [ + "a", + "a and b", + "a, b or c", + ]); + assertEquals([1, 2, 3, 4, 11, 12, 13, 21, 22].map(ordinal), [ + "1st", + "2nd", + "3rd", + "4th", + "11th", + "12th", + "13th", + "21st", + "22nd", + ]); + const now = at(2026, 6, 10, 10); + assertEquals( + [until(at(2026, 6, 10, 10, 5), now), until(at(2026, 6, 10, 13), now), until(at(2026, 6, 15, 10), now)], + ["in 5 min", "in 3 h", "in 5 days"], + ); + assertEquals(formatRun(at(2026, 10, 28, 9, 5, 30), true), "Wed 28 Oct 2026, 09:05:30"); + assertEquals( + timerUnit(["Mon..Fri *-*-* 09:00:00"], "At 09:00, on weekdays"), + "[Unit]\nDescription=At 09:00, on weekdays\n\n[Timer]\nOnCalendar=Mon..Fri *-*-* 09:00:00\nPersistent=true\n\n[Install]\nWantedBy=timers.target", + ); +}); diff --git a/extensions/crypto/api.ts b/extensions/crypto/api.ts new file mode 100644 index 0000000..05d46e0 --- /dev/null +++ b/extensions/crypto/api.ts @@ -0,0 +1,175 @@ +// The CoinGecko public API (no key needed, ~10-30 calls a minute), with a +// cache so that typing and reopening don't burn the rate limit. + +import { storage } from "@backslash/api"; +import type { Holding, Market, SearchCoin } from "./lib.ts"; + +const API = "https://api.coingecko.com/api/v3"; + +type Json = Parameters[1]; + +export class RateLimited extends Error {} + +export async function load(key: string): Promise { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise { + await storage.set(key, value as Json).catch(() => {}); +} + +interface Cached { + at: number; + data: T; +} + +const memory = new Map>(); + +/** Set when the last answer came from the cache because CoinGecko refused or was unreachable. */ +export let stale: { at: number; reason: string } | undefined; + +async function request(path: string, apiKey: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + signal, + headers: { Accept: "application/json", ...(apiKey ? { "x-cg-demo-api-key": apiKey } : {}) }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`CoinGecko is unreachable: ${(e as Error).message}`); + } + if (res.status === 429) { + await res.body?.cancel(); + const wait = Number(res.headers.get("retry-after")); + throw new RateLimited( + `CoinGecko's rate limit is reached${wait ? `, try again in ${wait} s` : ", try again in a minute"}`, + ); + } + if (res.status === 401 || res.status === 403) { + await res.body?.cancel(); + throw new Error(apiKey ? "CoinGecko rejected the Demo API key" : `CoinGecko refused the request (${res.status})`); + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`CoinGecko answered ${res.status}`); + } + return await res.json() as T; +} + +/** + * A GET answer, fresh for `ttl` seconds. When CoinGecko refuses (rate limit) or + * can't be reached, the last answer is used, however old, and `stale` says so. + */ +export async function cached(path: string, ttl: number, apiKey: string, signal?: AbortSignal): Promise { + const key = `cache:${path}`; + const hit = (memory.get(key) as Cached | undefined) ?? await load>(key); + if (hit && Date.now() - hit.at < ttl * 1000) { + memory.set(key, hit); + return hit.data; + } + try { + const data = await request(path, apiKey, signal); + const entry = { at: Date.now(), data }; + memory.set(key, entry); + await save(key, entry); + stale = undefined; + return data; + } catch (e) { + if ((e as Error).name === "AbortError" || !hit) throw e; + stale = { at: hit.at, reason: (e as Error).message }; + return hit.data; + } +} + +const marketsParams = (currency: string) => + `vs_currency=${currency}&sparkline=true&price_change_percentage=24h%2C7d&precision=full`; + +/** The top coins by market cap. */ +export function topMarkets(currency: string, apiKey: string, signal?: AbortSignal, count = 50): Promise { + return cached( + `/coins/markets?${marketsParams(currency)}&order=market_cap_desc&per_page=${count}&page=1`, + 90, + apiKey, + signal, + ); +} + +/** Markets of the given coins, in the given order. */ +export async function marketsOf( + ids: string[], + currency: string, + apiKey: string, + signal?: AbortSignal, +): Promise { + if (!ids.length) return []; + const sorted = [...new Set(ids)].sort(); + const found = await cached( + `/coins/markets?${marketsParams(currency)}&ids=${encodeURIComponent(sorted.join(","))}&per_page=250`, + 60, + apiKey, + signal, + ); + const byId = new Map(found.map((m) => [m.id, m])); + return ids.map((id) => byId.get(id)).filter((m): m is Market => !!m); +} + +export async function searchCoins(query: string, apiKey: string, signal?: AbortSignal): Promise { + const res = await cached<{ coins?: SearchCoin[] }>( + `/search?query=${encodeURIComponent(query.trim().toLowerCase())}`, + 3600, + apiKey, + signal, + ); + return res.coins ?? []; +} + +// --------------------------------------------------------------------------- +// Watchlist and portfolio + +/** Changes to the watchlist or portfolio, so live lists reload. */ +export const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +export const watchlist = async () => await load("watchlist") ?? []; + +export async function toggleWatch(id: string): Promise { + const list = await watchlist(); + const watched = !list.includes(id); + await save("watchlist", watched ? [...list, id] : list.filter((x) => x !== id)); + changed(); + return watched; +} + +export async function moveWatch(id: string, by: -1 | 1): Promise { + const list = await watchlist(); + const i = list.indexOf(id); + const j = i + by; + if (i < 0 || j < 0 || j >= list.length) return; + [list[i], list[j]] = [list[j], list[i]]; + await save("watchlist", list); + changed(); +} + +export const holdings = async () => await load("holdings") ?? []; + +export async function setHoldings(list: Holding[]): Promise { + await save("holdings", list); + changed(); +} + +/** Yields `load()` again each time the watchlist or portfolio changes, until `signal` aborts. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/crypto/backslash.toml b/extensions/crypto/backslash.toml new file mode 100644 index 0000000..2510123 --- /dev/null +++ b/extensions/crypto/backslash.toml @@ -0,0 +1,52 @@ +[extension] +id = "crypto" +title = "Crypto" +description = "Cryptocurrency prices, 24 h and 7 day changes, market caps and a watchlist from CoinGecko, and the value of your holdings" +version = "1.0.0" +author = "Backslash" +icon = "bs-coin" +license = "MIT" + +# The CoinGecko public API, and the hosts its coin logos are served from +# (coin-images is the current CDN, assets the older one still in some answers). +[permissions] +net = ["api.coingecko.com", "coin-images.coingecko.com", "assets.coingecko.com"] + +[[commands]] +name = "coins" +title = "Crypto Prices" +description = "Top coins by market cap and your watchlist; type to search any coin" +icon = "bs-coin" +keywords = [ + "crypto", + "bitcoin", + "btc", + "ethereum", + "coin", + "price", + "coingecko", + "market", + "cryptomonnaie", + "cours", + "prix", + "marché", +] + +[[commands]] +name = "portfolio" +title = "Crypto Portfolio" +description = "What your coins are worth, with each holding's share and 24 h change" +icon = "bs-chart-up" +keywords = ["crypto", "portfolio", "holdings", "wallet", "balance", "portefeuille", "avoirs", "solde"] + +[[preferences]] +name = "currency" +title = "Currency" +description = "Prices are shown in: usd, eur, gbp, jpy, chf, cad, aud, cny, inr, krw, brl, btc or eth" +default = "usd" + +[[preferences]] +name = "apiKey" +title = "Demo API key (optional)" +description = "A free CoinGecko Demo key raises the rate limit: coingecko.com/en/developers/dashboard" +type = "secret" diff --git a/extensions/crypto/commands/coins.ts b/extensions/crypto/commands/coins.ts new file mode 100644 index 0000000..a19854b --- /dev/null +++ b/extensions/crypto/commands/coins.ts @@ -0,0 +1,50 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { holdings, marketsOf, searchCoins, stale, topMarkets, watchlist } from "../api.ts"; +import { type Market, searchToMarket, tickerLine } from "../lib.ts"; +import { coinItem, guarded, settings } from "../views.ts"; + +const ago = (at: number) => { + const min = Math.round((Date.now() - at) / 60_000); + return min < 1 ? "just now" : min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`; +}; + +export default (ctx: Context) => { + const s = settings(ctx.preferences); + return list({ + placeholder: "Search coins by name or symbol…", + empty: { title: "No coin found", description: "Try a name (solana) or a symbol (SOL)", icon: Icons.coin }, + status: (items) => + items[0]?.id === "rate-limit" + ? "Rate limited" + : `${items.length} coin${items.length === 1 ? "" : "s"} · ${s.currency.toUpperCase()}${ + stale ? ` · CoinGecko is rate limiting, prices from ${ago(stale.at)}` : " · CoinGecko" + }`, + search: (query, { signal }) => + guarded(async () => { + const [watched, held] = await Promise.all([watchlist(), holdings()]); + const heldById = new Map(held.map((h) => [h.id, h])); + const item = (m: Market, section?: string) => + coinItem(m, { currency: s.currency, watched: watched.includes(m.id), held: heldById.get(m.id), section }); + + if (query.trim()) { + const found = (await searchCoins(query, s.apiKey, signal)).slice(0, 20); + const priced = await marketsOf(found.map((c) => c.id), s.currency, s.apiKey, signal).catch((e) => { + if ((e as Error).name === "AbortError") throw e; + return [] as Market[]; + }); + const byId = new Map(priced.map((m) => [m.id, m])); + return found.map((c) => item(byId.get(c.id) ?? searchToMarket(c))); + } + + const [mine, top] = await Promise.all([ + marketsOf(watched, s.currency, s.apiKey, signal), + topMarkets(s.currency, s.apiKey, signal), + ]); + const first = mine[0] ?? top[0]; + if (first) updateCommandMetadata({ subtitle: tickerLine(first, s.currency) }).catch(() => {}); + const items: Item[] = mine.map((m) => item(m, "Watchlist")); + for (const m of top) if (!watched.includes(m.id)) items.push(item(m, "Top Coins")); + return items; + }), + }); +}; diff --git a/extensions/crypto/commands/portfolio.ts b/extensions/crypto/commands/portfolio.ts new file mode 100644 index 0000000..5e4a519 --- /dev/null +++ b/extensions/crypto/commands/portfolio.ts @@ -0,0 +1,104 @@ +import { + action, + type Context, + copy, + Icons, + type Item, + list, + meta, + open, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { holdings, live, marketsOf, setHoldings, stale } from "../api.ts"; +import { amount, change24h, changeColor, coinUrl, logo, money, percent, signedMoney, valuePortfolio } from "../lib.ts"; +import { coinIcon, coinMetadata, coinPicker, holdingForm, settings } from "../views.ts"; + +let summary = ""; + +export default (ctx: Context) => { + const s = settings(ctx.preferences); + const add = action("Add Holding…", () => coinPicker(s), { icon: Icons.plus, shortcut: "n" }); + return list({ + placeholder: "Filter holdings…", + empty: { + title: "No holdings yet", + description: "Press Ctrl+N to add a coin and the amount you hold. Amounts stay on this computer.", + icon: Icons.coin, + }, + actions: [add], + status: () => summary, + items: ({ signal }) => + live(async (): Promise => { + const held = await holdings(); + if (!held.length) { + summary = ""; + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return []; + } + const markets = await marketsOf(held.map((h) => h.id), s.currency, s.apiKey, signal); + const { rows, total, change } = valuePortfolio(held, markets); + const pct = total - change ? (change / (total - change)) * 100 : 0; + summary = `Total ${money(total, s.currency)} · ${signedMoney(change, s.currency)} (${percent(pct)}) in 24 h${ + stale ? " · CoinGecko is rate limiting, saved prices" : "" + }`; + updateCommandMetadata({ subtitle: `Total ${money(total, s.currency)}` }).catch(() => {}); + + return rows.map(({ holding: h, market: m, value, change: diff, share }) => { + const symbol = h.symbol.toUpperCase(); + const c24 = m ? change24h(m) : undefined; + return { + id: h.id, + title: h.name, + subtitle: `${amount(h.amount)} ${symbol}`, + icon: coinIcon({ image: m?.image ?? h.image }), + keywords: [h.symbol, h.id], + accessories: [ + { text: `${Math.round(share * 100)} %`, tooltip: "Share of the portfolio" }, + ...(m + ? [{ + text: percent(c24), + color: changeColor(c24), + icon: { source: (c24 ?? 0) >= 0 ? Icons.chartUp : Icons.chartDown, tint: changeColor(c24) }, + tooltip: "24 h change", + }] + : []), + { text: money(value, s.currency), tooltip: m ? `at ${money(m.current_price, s.currency)}` : "No price" }, + ], + detail: { + image: logo(m?.image ?? h.image, "large"), + stats: [ + { label: "Value", value: money(value, s.currency) }, + { label: "24 h", value: signedMoney(diff, s.currency), color: changeColor(diff) }, + { label: "Share", value: `${Math.round(share * 100)} %` }, + ], + metadata: [ + meta.text("Amount", `${amount(h.amount)} ${symbol}`, { icon: Icons.coin }), + ...(m ? [meta.text("Price", money(m.current_price, s.currency))] : []), + meta.progress("Of portfolio", share), + meta.separator(), + ...(m ? coinMetadata(m, s.currency) : [meta.link("CoinGecko", h.id, coinUrl(h.id))]), + ], + }, + actions: [ + action("Edit Amount…", () => holdingForm(h, h), { icon: Icons.text }), + open(coinUrl(h.id), "Open on CoinGecko"), + add, + copy(money(value, s.currency), "Copy Value", { section: "Copy", shortcut: "c" }), + copy(String(h.amount), "Copy Amount", { section: "Copy" }), + action("Remove Holding", async () => { + await setHoldings((await holdings()).filter((x) => x.id !== h.id)); + await toast(`${h.name} removed from the portfolio`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove ${h.name} from the portfolio?`, + section: "Danger", + shortcut: "Delete", + }), + ], + }; + }); + }, signal), + }); +}; diff --git a/extensions/crypto/lib.ts b/extensions/crypto/lib.ts new file mode 100644 index 0000000..dc7e54a --- /dev/null +++ b/extensions/crypto/lib.ts @@ -0,0 +1,244 @@ +// CoinGecko answers, money formatting and text sparklines. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** One coin of `GET /coins/markets`. */ +export interface Market { + id: string; + symbol: string; + name: string; + image?: string | null; + current_price: number | null; + market_cap: number | null; + market_cap_rank: number | null; + fully_diluted_valuation?: number | null; + total_volume: number | null; + high_24h?: number | null; + low_24h?: number | null; + price_change_24h?: number | null; + price_change_percentage_24h?: number | null; + price_change_percentage_24h_in_currency?: number | null; + price_change_percentage_7d_in_currency?: number | null; + circulating_supply?: number | null; + total_supply?: number | null; + max_supply?: number | null; + ath?: number | null; + ath_change_percentage?: number | null; + ath_date?: string | null; + last_updated?: string | null; + sparkline_in_7d?: { price: number[] } | null; +} + +/** One coin of `GET /search`. */ +export interface SearchCoin { + id: string; + name: string; + symbol: string; + market_cap_rank: number | null; + thumb?: string; + large?: string; +} + +/** A holding of the portfolio, as stored. */ +export interface Holding { + id: string; + symbol: string; + name: string; + image?: string; + amount: number; +} + +// --------------------------------------------------------------------------- +// Currency + +export const CURRENCIES = ["usd", "eur", "gbp", "jpy", "chf", "cad", "aud", "cny", "inr", "krw", "brl", "btc", "eth"]; + +/** The `currency` preference, lowercased; anything unknown falls back to usd. */ +export function normalizeCurrency(input: unknown): string { + const c = String(input ?? "").trim().toLowerCase().replace(/[^a-z]/g, ""); + return CURRENCIES.includes(c) ? c : "usd"; +} + +const CRYPTO_UNITS: Record = { btc: "₿", eth: "Ξ" }; + +/** Enough digits for a price: 67,123.45 · 0.6123 · 0.00001234. */ +function priceDigits(value: number): Intl.NumberFormatOptions { + const abs = Math.abs(value); + if (abs === 0 || abs >= 1) return { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + return { maximumSignificantDigits: 4 }; +} + +/** `$67,123.45`, `€0.6123`, `₿0.0234`. */ +export function money(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const unit = CRYPTO_UNITS[currency]; + if (unit) return `${unit}${new Intl.NumberFormat("en-US", priceDigits(value)).format(value)}`; + return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), ...priceDigits(value) }) + .format(value); +} + +/** `$1.32T`, `€45.6B`: market caps and volumes. */ +export function compactMoney(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const options: Intl.NumberFormatOptions = { notation: "compact", maximumFractionDigits: 2 }; + const unit = CRYPTO_UNITS[currency]; + if (unit) return `${unit}${new Intl.NumberFormat("en-US", options).format(value)}`; + return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), ...options }) + .format(value); +} + +/** `+$209.14`, `−€3.20`: a change of value. */ +export function signedMoney(value: number | null | undefined, currency: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value > 0 ? "+" : value < 0 ? "−" : ""}${money(Math.abs(value), currency)}`; +} + +/** `19.7M`: supplies. */ +export const compact = (value: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(value); + +/** `+2.31 %`, `−0.84 %`. */ +export function percent(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + return `${sign}${Math.abs(value).toFixed(2)} %`; +} + +/** Green when up, red when down. */ +export const changeColor = (value: number | null | undefined): Color | undefined => + !value ? undefined : value > 0 ? "green" : "red"; + +/** An amount of coins: `0.5`, `1,250`, `0.00012`. */ +export const amount = (value: number) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value); + +/** `"1,5"`, `"0.25 "`, `"2e-3"` → a positive number, or undefined. */ +export function parseAmount(input: string): number | undefined { + let text = input.trim().replace(/\s/g, ""); + // One comma and no dot is a decimal comma (1,5); otherwise commas group thousands (1,250,000.5). + text = /^[^,.]*,[^,.]*$/.test(text) ? text.replace(",", ".") : text.replace(/,/g, ""); + if (!/^(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(text)) return undefined; + const n = Number(text); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + +// --------------------------------------------------------------------------- +// Sparklines + +const BARS = "▁▂▃▄▅▆▇█"; + +/** Prices → `▂▃▅▇█▆▅▃`, averaged into `width` buckets. */ +export function sparkline(prices: number[] | null | undefined, width = 28): string { + const values = (prices ?? []).filter((p) => Number.isFinite(p)); + if (values.length < 2) return ""; + const n = Math.min(width, values.length); + const buckets: number[] = []; + for (let i = 0; i < n; i++) { + const slice = values.slice(Math.floor((i * values.length) / n), Math.floor(((i + 1) * values.length) / n)); + buckets.push(slice.reduce((a, b) => a + b, 0) / slice.length); + } + const min = Math.min(...buckets); + const max = Math.max(...buckets); + if (max === min) return BARS[3].repeat(n); + return buckets.map((v) => BARS[Math.round(((v - min) / (max - min)) * (BARS.length - 1))]).join(""); +} + +/** The change over the sparkline's window, when the API didn't give one. */ +export function sparkChange(prices: number[] | null | undefined): number | undefined { + const values = (prices ?? []).filter((p) => Number.isFinite(p)); + if (values.length < 2 || !values[0]) return undefined; + return ((values[values.length - 1] - values[0]) / values[0]) * 100; +} + +// --------------------------------------------------------------------------- +// Coins + +export const change24h = (m: Market) => m.price_change_percentage_24h_in_currency ?? m.price_change_percentage_24h; +export const change7d = (m: Market) => + m.price_change_percentage_7d_in_currency ?? sparkChange(m.sparkline_in_7d?.price); + +export const coinUrl = (id: string) => `https://www.coingecko.com/en/coins/${encodeURIComponent(id)}`; + +/** The coin's logo in a size, from any of CoinGecko's sizes (`thumb`, `small`, `large`). */ +export function logo(url: string | null | undefined, size: "thumb" | "small" | "large"): string | undefined { + if (!url || !/^https:\/\//.test(url) || url.includes("missing_")) return undefined; + return url.replace(/\/(thumb|small|large)\//, `/${size}/`); +} + +/** "Price · ▲ 2.31 %" for the root search subtitle. */ +export function tickerLine(m: Market, currency: string): string { + const c = change24h(m); + const arrow = c === null || c === undefined ? "" : c >= 0 ? " ▲" : " ▼"; + return `${m.symbol.toUpperCase()} ${money(m.current_price, currency)}${arrow}${ + c === null || c === undefined ? "" : ` ${Math.abs(c).toFixed(1)}%` + }`; +} + +/** "18 Mar 2024", or empty. */ +export function shortDate(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +// --------------------------------------------------------------------------- +// Portfolio + +export interface Valued { + holding: Holding; + market?: Market; + value?: number; + /** Change of the holding's value over 24 h, in the currency. */ + change?: number; + share: number; +} + +/** Each holding with its value and share of the total, biggest first. */ +export function valuePortfolio( + holdings: Holding[], + markets: Market[], +): { rows: Valued[]; total: number; change: number } { + const byId = new Map(markets.map((m) => [m.id, m])); + const rows: Valued[] = holdings.map((holding) => { + const market = byId.get(holding.id); + const price = market?.current_price ?? undefined; + const value = price === undefined ? undefined : price * holding.amount; + const pct = market ? change24h(market) : undefined; + const change = value === undefined || pct === null || pct === undefined + ? undefined + : value - value / (1 + pct / 100); + return { holding, market, value, change, share: 0 }; + }); + const total = rows.reduce((sum, r) => sum + (r.value ?? 0), 0); + const change = rows.reduce((sum, r) => sum + (r.change ?? 0), 0); + for (const r of rows) r.share = total > 0 && r.value ? r.value / total : 0; + rows.sort((a, b) => (b.value ?? -1) - (a.value ?? -1)); + return { rows, total, change }; +} + +/** Adds to an existing holding of the same coin, or appends it. */ +export function upsertHolding(holdings: Holding[], h: Holding, mode: "set" | "add" = "set"): Holding[] { + const existing = holdings.find((x) => x.id === h.id); + if (!existing) return [...holdings, h]; + return holdings.map((x) => + x.id === h.id ? { ...x, ...h, amount: mode === "add" ? x.amount + h.amount : h.amount } : x + ); +} + +/** A market for a coin found by search, before its price is known. */ +export function searchToMarket(c: SearchCoin): Market { + return { + id: c.id, + symbol: c.symbol, + name: c.name, + image: c.large ?? c.thumb, + current_price: null, + market_cap: null, + market_cap_rank: c.market_cap_rank, + total_volume: null, + }; +} diff --git a/extensions/crypto/lib_test.ts b/extensions/crypto/lib_test.ts new file mode 100644 index 0000000..387082d --- /dev/null +++ b/extensions/crypto/lib_test.ts @@ -0,0 +1,185 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + amount, + change7d, + changeColor, + compactMoney, + type Holding, + logo, + type Market, + money, + normalizeCurrency, + parseAmount, + percent, + searchToMarket, + signedMoney, + sparkChange, + sparkline, + tickerLine, + upsertHolding, + valuePortfolio, +} from "./lib.ts"; +import { coinItem, coinMetadata } from "./views.ts"; + +// Shaped like `GET /coins/markets?vs_currency=usd&sparkline=true&price_change_percentage=24h,7d`. +const bitcoin: Market = { + id: "bitcoin", + symbol: "btc", + name: "Bitcoin", + image: "https://coin-images.coingecko.com/coins/images/1/large/bitcoin.png?1696501400", + current_price: 67123.45, + market_cap: 1323456789012, + market_cap_rank: 1, + fully_diluted_valuation: 1409876543210, + total_volume: 28123456789, + high_24h: 67890, + low_24h: 65432.1, + price_change_24h: 1512.3, + price_change_percentage_24h: 2.305, + price_change_percentage_24h_in_currency: 2.305, + price_change_percentage_7d_in_currency: -1.42, + circulating_supply: 19765431, + total_supply: 21000000, + max_supply: 21000000, + ath: 73738, + ath_change_percentage: -8.97, + ath_date: "2024-03-14T07:10:36.635Z", + last_updated: "2026-10-28T09:58:12.123Z", + sparkline_in_7d: { price: [68000, 67500, 66000, 65000, 65500, 66500, 67000, 67123] }, +}; + +const ethereum: Market = { + ...bitcoin, + id: "ethereum", + symbol: "eth", + name: "Ethereum", + image: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + current_price: 2500, + market_cap_rank: 2, + price_change_percentage_24h_in_currency: -4, + price_change_percentage_7d_in_currency: null, + max_supply: null, + total_supply: 120000000, + sparkline_in_7d: { price: [2400, 2450, 2500] }, +}; + +Deno.test("currency preference", () => { + assertEquals(normalizeCurrency("EUR"), "eur"); + assertEquals(normalizeCurrency(" usd "), "usd"); + assertEquals(normalizeCurrency("dogecoin"), "usd"); + assertEquals(normalizeCurrency(undefined), "usd"); +}); + +Deno.test("money", () => { + assertEquals(money(67123.45, "usd"), "$67,123.45"); + assertEquals(money(0.61234567, "usd"), "$0.6123"); + assertEquals(money(0.0000123456, "eur"), "€0.00001235"); + assertEquals(money(0.0234, "btc"), "₿0.0234"); + assertEquals(money(1234, "jpy"), "¥1,234.00"); + assertEquals(money(null, "usd"), "—"); + assertEquals(compactMoney(1323456789012, "usd"), "$1.32T"); + assertEquals(compactMoney(45600000000, "eur"), "€45.6B"); + assertEquals(compactMoney(undefined, "usd"), "—"); + assertEquals(signedMoney(209.14, "usd"), "+$209.14"); + assertEquals(signedMoney(-3.2, "eur"), "−€3.20"); +}); + +Deno.test("percent and colors", () => { + assertEquals(percent(2.305), "+2.31 %"); + assertEquals(percent(-0.844), "−0.84 %"); + assertEquals(percent(0), "0.00 %"); + assertEquals(percent(null), "—"); + assertEquals(changeColor(1), "green"); + assertEquals(changeColor(-1), "red"); + assertEquals(changeColor(0), undefined); +}); + +Deno.test("amounts typed by the user", () => { + assertEquals(parseAmount("0.25"), 0.25); + assertEquals(parseAmount("1,5"), 1.5); + assertEquals(parseAmount("1,250"), 1.25); + assertEquals(parseAmount("1,250,000"), 1250000); + assertEquals(parseAmount(" 3 "), 3); + assertEquals(parseAmount("2e-3"), 0.002); + assertEquals(parseAmount("-1"), undefined); + assertEquals(parseAmount("0"), undefined); + assertEquals(parseAmount("abc"), undefined); + assertEquals(amount(0.00012345), "0.00012345"); + assertEquals(amount(1250), "1,250"); +}); + +Deno.test("sparklines", () => { + assertEquals(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), "▁▂▃▄▅▆▇█"); + assertEquals(sparkline([5, 5, 5]), "▄▄▄"); + assertEquals(sparkline([1]), ""); + assertEquals(sparkline(undefined), ""); + const week = Array.from({ length: 168 }, (_, i) => 100 + Math.sin(i / 10) * 5); + assertEquals([...sparkline(week)].length, 28); + assertEquals(sparkChange([100, 110]), 10); + assertEquals(change7d(ethereum)?.toFixed(2), "4.17"); + assertEquals(change7d(bitcoin), -1.42); +}); + +Deno.test("logos in any size", () => { + assertEquals( + logo(bitcoin.image, "small"), + "https://coin-images.coingecko.com/coins/images/1/small/bitcoin.png?1696501400", + ); + assertEquals( + logo("https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png", "large"), + "https://assets.coingecko.com/coins/images/1/large/bitcoin.png", + ); + assertEquals(logo("missing_large.png", "large"), undefined); + assertEquals(logo(null, "large"), undefined); +}); + +Deno.test("ticker line", () => { + assertEquals(tickerLine(bitcoin, "usd"), "BTC $67,123.45 ▲ 2.3%"); + assertEquals(tickerLine(ethereum, "eur"), "ETH €2,500.00 ▼ 4.0%"); +}); + +Deno.test("portfolio values", () => { + const held: Holding[] = [ + { id: "ethereum", symbol: "eth", name: "Ethereum", amount: 2 }, + { id: "bitcoin", symbol: "btc", name: "Bitcoin", amount: 0.1 }, + { id: "gone-coin", symbol: "gone", name: "Gone", amount: 10 }, + ]; + const { rows, total, change } = valuePortfolio(held, [bitcoin, ethereum]); + assertEquals(rows.map((r) => r.holding.id), ["bitcoin", "ethereum", "gone-coin"]); + assertEquals(Math.round(total * 100) / 100, 11712.35); + assertEquals(Math.round(rows[0].share * 1000) / 1000, 0.573); + assertEquals(rows[2].value, undefined); + assertEquals(rows[2].share, 0); + // +2.305 % on 6712.35, −4 % on 5000. + assertEquals(Math.round(change), Math.round(6712.345 - 6712.345 / 1.02305 + 5000 - 5000 / 0.96)); +}); + +Deno.test("adding to a holding", () => { + const h: Holding = { id: "bitcoin", symbol: "btc", name: "Bitcoin", amount: 0.1 }; + assertEquals(upsertHolding([], h), [h]); + assertEquals(upsertHolding([h], { ...h, amount: 0.2 }, "add")[0].amount, 0.30000000000000004); + assertEquals(upsertHolding([h], { ...h, amount: 0.5 })[0].amount, 0.5); +}); + +Deno.test("coin item", () => { + const item = coinItem(bitcoin, { currency: "usd", watched: true, section: "Watchlist" }); + assertEquals(item.title, "Bitcoin"); + assertEquals(item.subtitle, "BTC"); + assertEquals(item.icon, "https://coin-images.coingecko.com/coins/images/1/small/bitcoin.png?1696501400"); + assertEquals(item.detail?.image, bitcoin.image); + assertEquals(item.detail?.stats?.[1], { label: "24 h", value: "+2.31 %", color: "green" }); + assertEquals(item.accessories?.at(-1)?.text, "$67,123.45"); + assertEquals(item.actions?.[1].title, "Remove from Watchlist"); + const lines = coinMetadata(bitcoin, "usd"); + assertEquals(lines[0].label, "7 days"); + assertEquals(lines[0].color, "red"); + assertStringIncludes(lines.find((l) => l.label === "Supply")?.value ?? "", "19.77M of 21M BTC"); + assertStringIncludes(lines.find((l) => l.label === "All-time high")?.value ?? "", "14 Mar 2024"); + + const unpriced = coinItem(searchToMarket({ id: "new-coin", name: "New", symbol: "NEW", market_cap_rank: null }), { + currency: "usd", + watched: false, + }); + assertEquals(unpriced.icon, { source: "bs-coin", tint: "yellow" }); + assertEquals(unpriced.detail?.stats, undefined); +}); diff --git a/extensions/crypto/views.ts b/extensions/crypto/views.ts new file mode 100644 index 0000000..f95fed9 --- /dev/null +++ b/extensions/crypto/views.ts @@ -0,0 +1,261 @@ +// Coins as launcher items, the holding form and the coin picker. + +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + pop, + refresh, + replace, + toast, +} from "@backslash/api"; +import { holdings, RateLimited, searchCoins, setHoldings, stale, toggleWatch } from "./api.ts"; +import { + amount, + change24h, + change7d, + changeColor, + coinUrl, + compact, + compactMoney, + type Holding, + logo, + type Market, + money, + normalizeCurrency, + parseAmount, + percent, + searchToMarket, + shortDate, + sparkline, + upsertHolding, +} from "./lib.ts"; + +export interface Settings { + currency: string; + apiKey: string; +} + +export const settings = (preferences: Record): Settings => ({ + currency: normalizeCurrency(preferences.currency), + apiKey: String(preferences.apiKey ?? "").trim(), +}); + +export const KEY_URL = "https://www.coingecko.com/en/developers/dashboard"; + +/** Shown instead of results when CoinGecko refuses and nothing is saved. */ +export function rateLimitItem(e: RateLimited): Item { + return { + id: "rate-limit", + title: e.message, + subtitle: "The public API allows a few calls a minute", + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { + markdown: [ + "## CoinGecko is rate limiting", + "", + "The free public API allows only a few requests a minute per IP address. Prices are cached", + "for a minute or two, so this passes quickly.", + "", + "For a higher limit, create a free **Demo API key** (Enter opens the dashboard) and paste it in", + "**Settings → Extensions → Crypto → Configure**.", + ].join("\n"), + }, + actions: [open(KEY_URL, "Get a Free Demo API Key", { icon: Icons.key })], + }; +} + +/** Runs `load`, turning a rate limit without saved data into an explanation row. */ +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof RateLimited) return [rateLimitItem(e)]; + throw e; + } +} + +export function coinIcon(m: Pick) { + return logo(m.image, "small") ?? { source: Icons.coin, tint: "yellow" as const }; +} + +/** The detail panel's lines for a coin. */ +export function coinMetadata(m: Market, currency: string): Metadata[] { + const spark = sparkline(m.sparkline_in_7d?.price); + const lines: Metadata[] = []; + if (spark) lines.push(meta.text("7 days", spark, { color: changeColor(change7d(m)) })); + if (m.low_24h != null && m.high_24h != null) { + lines.push(meta.text("24 h range", `${money(m.low_24h, currency)} – ${money(m.high_24h, currency)}`)); + } + lines.push( + meta.text("Market cap", compactMoney(m.market_cap, currency), { icon: Icons.coin }), + meta.text("Volume 24 h", compactMoney(m.total_volume, currency), { icon: Icons.activity }), + ); + if (m.fully_diluted_valuation) { + lines.push(meta.text("Fully diluted", compactMoney(m.fully_diluted_valuation, currency))); + } + if (m.circulating_supply) { + const max = m.max_supply ?? m.total_supply; + lines.push( + max + ? meta.progress( + "Supply", + m.circulating_supply / max, + `${compact(m.circulating_supply)} of ${compact(max)} ${m.symbol.toUpperCase()}`, + ) + : meta.text("Supply", `${compact(m.circulating_supply)} ${m.symbol.toUpperCase()}`), + ); + } + if (m.ath) { + lines.push( + meta.text( + "All-time high", + `${money(m.ath, currency)} · ${percent(m.ath_change_percentage)}${ + m.ath_date ? ` · ${shortDate(m.ath_date)}` : "" + }`, + { icon: Icons.flag }, + ), + ); + } + lines.push(meta.separator(), meta.link("CoinGecko", `coingecko.com/en/coins/${m.id}`, coinUrl(m.id))); + return lines; +} + +/** A coin in the prices list. */ +export function coinItem( + m: Market, + o: { currency: string; watched: boolean; held?: Holding; section?: string }, +): Item { + const c24 = change24h(m); + const c7 = change7d(m); + const symbol = m.symbol.toUpperCase(); + const priced = m.current_price !== null && m.current_price !== undefined; + const actions: Action[] = [ + open(coinUrl(m.id), "Open on CoinGecko"), + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const now = await toggleWatch(m.id); + await toast(now ? `${m.name} added to the watchlist` : `${m.name} removed from the watchlist`, { + style: "success", + }); + return refresh(); + }, { icon: o.watched ? Icons.starFilled : Icons.star, shortcut: "s" }), + action(o.held ? "Edit Holding…" : "Add to Portfolio…", () => holdingForm(m, o.held), { + icon: Icons.plus, + shortcut: "p", + }), + ...(priced + ? [copy(money(m.current_price, o.currency), "Copy Price", { shortcut: "c", section: "Copy" })] + : []), + copy(symbol, "Copy Symbol", { section: "Copy" }), + copy(coinUrl(m.id), "Copy CoinGecko Link", { section: "Copy" }), + ]; + return { + id: `${o.section ?? ""}:${m.id}`, + title: m.name, + subtitle: symbol, + icon: coinIcon(m), + section: o.section, + keywords: [m.symbol, m.id], + accessories: [ + ...(o.watched + ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "In your watchlist" }] + : []), + ...(o.held + ? [{ tag: `${amount(o.held.amount)} ${symbol}`, color: "blue" as const, tooltip: "In your portfolio" }] + : []), + ...(priced + ? [ + { + text: percent(c24), + color: changeColor(c24), + icon: { source: (c24 ?? 0) >= 0 ? Icons.chartUp : Icons.chartDown, tint: changeColor(c24) }, + tooltip: "24 h change", + }, + { + text: money(m.current_price, o.currency), + tooltip: m.market_cap_rank ? `#${m.market_cap_rank}` : undefined, + }, + ] + : m.market_cap_rank + ? [{ text: `#${m.market_cap_rank}`, tooltip: "Market cap rank" }] + : []), + ], + detail: { + image: logo(m.image, "large"), + stats: priced + ? [ + { label: "Price", value: money(m.current_price, o.currency) }, + { label: "24 h", value: percent(c24), color: changeColor(c24) }, + { label: "7 d", value: percent(c7), color: changeColor(c7) }, + ...(m.market_cap_rank ? [{ label: "Rank", value: `#${m.market_cap_rank}` }] : []), + ] + : m.market_cap_rank + ? [{ label: "Rank", value: `#${m.market_cap_rank}` }] + : undefined, + markdown: priced ? undefined : "CoinGecko has no current price for this coin.", + metadata: priced ? coinMetadata(m, o.currency) : [meta.link("CoinGecko", m.id, coinUrl(m.id))], + }, + actions, + }; +} + +/** Amount of a coin to hold; saving goes back to the list (which reloads itself). */ +export function holdingForm(m: Pick, held?: Holding): FormSpec { + const symbol = m.symbol.toUpperCase(); + return form({ + title: held ? `${m.name} Holding` : `Add ${m.name} to Portfolio`, + submitTitle: held ? "Save Holding" : "Add Holding", + fields: [ + field.text("amount", `Amount (${symbol})`, { + placeholder: "e.g. 0.25", + required: true, + value: held ? String(held.amount) : "", + }), + ...(held ? [] : [field.checkbox("add", "Add to the amount I already hold", { value: true })]), + ], + async submit(values) { + const n = parseAmount(String(values.amount ?? "")); + if (n === undefined) throw new Error(`“${values.amount}” is not an amount of ${symbol}`); + const h: Holding = { id: m.id, symbol: m.symbol, name: m.name, image: m.image ?? undefined, amount: n }; + await setHoldings(upsertHolding(await holdings(), h, values.add ? "add" : "set")); + await toast(`${amount(n)} ${symbol} ${held ? "saved" : "added to the portfolio"}`, { style: "success" }); + return pop(); + }, + }); +} + +/** Search any coin, then enter the amount (replaces this list, so saving lands back on the portfolio). */ +export function coinPicker(s: Settings): ListSpec { + return list({ + title: "Add Holding", + placeholder: "Search a coin: bitcoin, sol, usdc…", + empty: { title: "Type a coin's name or symbol", icon: Icons.search }, + status: () => stale ? "CoinGecko is rate limiting: showing saved results" : "CoinGecko", + search: (query, { signal }) => + guarded(async () => { + if (!query.trim()) return []; + const coins = await searchCoins(query, s.apiKey, signal); + return coins.slice(0, 25).map((c) => { + const m = searchToMarket(c); + return { + id: c.id, + title: c.name, + subtitle: c.symbol.toUpperCase(), + icon: coinIcon({ image: c.thumb }), + accessories: c.market_cap_rank ? [{ text: `#${c.market_cap_rank}`, tooltip: "Market cap rank" }] : [], + actions: [action("Choose", () => replace(holdingForm(m)), { icon: Icons.check })], + }; + }); + }), + }); +} diff --git a/extensions/databases/backslash.toml b/extensions/databases/backslash.toml new file mode 100644 index 0000000..2b40cd0 --- /dev/null +++ b/extensions/databases/backslash.toml @@ -0,0 +1,78 @@ +[extension] +id = "databases" +title = "Databases" +description = "Open your PostgreSQL, MySQL, SQLite and Redis connections in a terminal, check that they answer and copy their URLs" +version = "1.0.0" +author = "Backslash" +icon = "bs-database" +license = "MIT" + +[permissions] +env = ["HOME"] +# Connection files the clients read themselves; the distribution, for install hints. +read = ["~/.pgpass", "~/.pg_service.conf", "~/.my.cnf", "/etc/os-release"] +run = [ + # Clients (opened in a terminal) and status checks. + "psql", + "pgcli", + "pg_isready", + "mysql", + "mariadb", + "mycli", + "mysqladmin", + "sqlite3", + "litecli", + "redis-cli", + # Terminals. + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "connections" +title = "Database Connections" +description = "Saved connections and those of ~/.pgpass, ~/.pg_service.conf and ~/.my.cnf, with their status" +icon = "bs-database" +keywords = [ + "database", + "db", + "postgres", + "postgresql", + "psql", + "mysql", + "mariadb", + "sqlite", + "redis", + "sql", + "base de données", + "bdd", + "connexion", +] + +[[commands]] +name = "add" +title = "Add Database Connection" +description = "Save a PostgreSQL, MySQL, SQLite or Redis connection (no password stored)" +icon = "bs-plus" +keywords = ["database", "db", "connection", "postgres", "mysql", "redis", "sqlite", "ajouter", "base de données", "connexion"] + +[[preferences]] +name = "passwords" +title = "Passwords" +description = "Passwords of saved connections as name=password pairs separated by ; (escape a ; with \\;). PostgreSQL connections also use ~/.pgpass" +type = "secret" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/databases/commands/add.ts b/extensions/databases/commands/add.ts new file mode 100644 index 0000000..c28c44c --- /dev/null +++ b/extensions/databases/commands/add.ts @@ -0,0 +1,6 @@ +import type { Context } from "@backslash/api"; +import { connectionForm } from "../db.ts"; + +// Text typed before opening the command becomes the name. +export default (ctx: Context) => + connectionForm({ connection: undefined, standalone: true, name: ctx.query.trim() || undefined }); diff --git a/extensions/databases/commands/connections.ts b/extensions/databases/commands/connections.ts new file mode 100644 index 0000000..4219ece --- /dev/null +++ b/extensions/databases/commands/connections.ts @@ -0,0 +1,241 @@ +import { + type Action, + action, + close, + type Context, + copy, + Icons, + type Item, + list, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Client, + clientCommand, + CLIENTS, + type Connection, + connectionUrl, + type DbType, + describe, + type Family, + HEALTH_COLORS, + installCommand, + SOURCE_NAMES, + type Status, + TYPE_COLORS, + TYPE_NAMES, +} from "../lib.ts"; +import { + checkStatus, + connectionForm, + copySecret, + deleteConnection, + distro, + HOME, + installedClients, + loadSources, + missingClientItem, + nextChange, + openIn, + passwordOf, + type Prefs, + savedList, + type Sources, +} from "../db.ts"; +import { shellLine } from "../terminal.ts"; + +const CLIENT_NAMES: Record = { + psql: "psql", + pgcli: "pgcli", + mysql: "mysql", + mariadb: "mariadb", + mycli: "mycli", + sqlite3: "sqlite3", + litecli: "litecli", + "redis-cli": "redis-cli", +}; + +const TYPE_ICON: Record = { + postgres: Icons.database, + mysql: Icons.database, + sqlite: Icons.file, + redis: Icons.bolt, +}; + +function metadata(c: Connection, status: Status | undefined, origin: string | undefined): Metadata[] { + const lines: Metadata[] = [ + meta.tags("Type", [{ text: TYPE_NAMES[c.type], color: TYPE_COLORS[c.type] }]), + meta.text("Status", status?.text ?? "Checking…", { + icon: Icons.dot, + color: status ? HEALTH_COLORS[status.health] : "gray", + }), + ]; + if (c.type === "sqlite") { + lines.push(meta.text("File", describe(c, HOME), { icon: Icons.file })); + } else { + if (c.service) lines.push(meta.text("Service", c.service, { icon: Icons.settings })); + if (c.host || !c.socket) lines.push(meta.text("Host", c.host ?? "localhost", { icon: Icons.server })); + if (c.socket) lines.push(meta.text("Socket", c.socket, { icon: Icons.plug })); + if (c.port) lines.push(meta.text("Port", c.port, { icon: Icons.hash })); + if (c.database) lines.push(meta.text(c.type === "redis" ? "Database #" : "Database", c.database)); + if (c.user) lines.push(meta.person("User", c.user)); + lines.push(meta.text("Password", origin ?? "None known", { + icon: origin ? Icons.lock : Icons.key, + color: origin ? "green" : "gray", + })); + } + lines.push(meta.separator(), meta.text("Source", SOURCE_NAMES[c.source], { icon: Icons.folder })); + return lines; +} + +function item( + c: Connection, + src: Sources, + prefs: Prefs, + clients: Set, + family: Family | undefined, + status?: Status, +): Item { + const { password, origin } = passwordOf(c, src, prefs); + const available = CLIENTS[c.type].filter((cl) => clients.has(cl)); + const [primary, ...others] = available; + const actions: Action[] = []; + const openAction = (client: Client, o: { shortcut?: string } = {}) => + action(`Open in ${CLIENT_NAMES[client]}`, () => { + const term = openIn(c, client, src, prefs); + return close(`${CLIENT_NAMES[client]} in ${term}`); + }, { icon: Icons.terminal, ...o }); + if (primary) { + actions.push(openAction(primary)); + others.forEach((cl, i) => actions.push(openAction(cl, i === 0 ? { shortcut: "o" } : {}))); + } else { + actions.push(copy(installCommand(c.type, family), `Copy ${TYPE_NAMES[c.type]} Client Install Command`, { + icon: Icons.download, + })); + } + actions.push(action("Check Status", () => refresh(), { icon: Icons.activity, shortcut: "r" })); + + const url = connectionUrl(c, password); + actions.push(copy(url, password ? "Copy URL (Password Masked)" : "Copy URL", { + icon: Icons.link, + shortcut: "c", + section: "Copy", + })); + if (password) { + actions.push(copySecret(connectionUrl(c, password, true), "Copy URL with Password", { section: "Copy" })); + actions.push(copySecret(password, "Copy Password", { section: "Copy" })); + } + const shown = primary ?? CLIENTS[c.type][0]; + const cmd = clientCommand(c, shown, undefined).argv; + actions.push(copy(shellLine(cmd), `Copy ${CLIENT_NAMES[shown]} Command`, { icon: Icons.terminal, section: "Copy" })); + if (c.host) actions.push(copy(c.host, "Copy Host", { icon: Icons.server, section: "Copy" })); + if (c.path) actions.push(copy(c.path, "Copy File Path", { icon: Icons.file, section: "Copy" })); + + if (c.source === "saved") { + actions.push( + action("Edit Connection", async () => { + const saved = (await savedList()).find((s) => s.name === c.name); + if (!saved) throw new Error(`${c.name} is no longer saved`); + return connectionForm({ connection: saved }); + }, { icon: Icons.settings, shortcut: "e", section: "Connection" }), + action("Delete Connection", async () => { + await deleteConnection(c.name); + await toast(`Deleted ${c.name}`, { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete the saved connection “${c.name}”? The database itself is not touched.`, + shortcut: "Delete", + section: "Danger", + }), + ); + } else { + actions.push(open(`${HOME}/${SOURCE_NAMES[c.source].slice(2)}`, `Open ${SOURCE_NAMES[c.source]}`, { + icon: Icons.file, + section: "Connection", + })); + } + + const color = status ? HEALTH_COLORS[status.health] : "gray"; + return { + id: c.id, + title: c.name, + subtitle: describe(c, HOME), + icon: { source: TYPE_ICON[c.type], tint: TYPE_COLORS[c.type] }, + section: SOURCE_NAMES[c.source], + keywords: [c.type, TYPE_NAMES[c.type], c.host ?? "", c.database ?? "", c.user ?? "", c.service ?? ""], + accessories: [ + ...(origin ? [{ icon: Icons.lock, tooltip: `Password in ${origin}` }] : []), + { tag: TYPE_NAMES[c.type].split(" ")[0], color: TYPE_COLORS[c.type] }, + { + icon: { source: Icons.dot, tint: color }, + text: status?.text ?? "…", + tooltip: status ? "Status" : "Checking…", + }, + ], + detail: { metadata: metadata(c, status, origin) }, + actions, + }; +} + +let summary = ""; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + const addAction = action("Add Connection", () => connectionForm(), { icon: Icons.plus, shortcut: "n" }); + return list({ + placeholder: "Filter connections by name, host, database…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "postgres", title: "PostgreSQL" }, + { value: "mysql", title: "MySQL" }, + { value: "sqlite", title: "SQLite" }, + { value: "redis", title: "Redis" }, + ], + default: "all", + }, + empty: { + title: "No database connections", + description: "Press Ctrl+N to add one, or describe them in ~/.pgpass, ~/.pg_service.conf or ~/.my.cnf", + icon: Icons.database, + }, + status: () => summary, + actions: [addAction], + async *items({ signal, filter }) { + while (!signal.aborted) { + const [src, clients, family] = await Promise.all([loadSources(), installedClients(), distro()]); + const shown = src.connections.filter((c) => !filter || filter === "all" || c.type === filter); + const missing = await Promise.all( + [...new Set(shown.map((c) => c.type))] + .filter((t) => !CLIENTS[t].some((cl) => clients.has(cl))) + .map(missingClientItem), + ); + const withAdd = (items: Item[]) => [...items, ...missing]; + summary = shown.length ? `${shown.length} connection${shown.length === 1 ? "" : "s"} · checking…` : ""; + yield withAdd(shown.map((c) => item(c, src, prefs, clients, family))); + + const statuses = await Promise.all(shown.map((c) => checkStatus(c, signal))); + if (signal.aborted) return; + const up = statuses.filter((s) => s.health === "up" || s.health === "auth").length; + summary = !shown.length + ? "" + : `${shown.length} connection${shown.length === 1 ? "" : "s"} · ${up} up${ + src.files.length ? ` · ${src.files.join(", ")}` : "" + }`; + if (!filter || filter === "all") { + await updateCommandMetadata({ + subtitle: src.connections.length ? `${src.connections.length} connections · ${up} up` : null, + }).catch(() => {}); + } + yield withAdd(shown.map((c, i) => item(c, src, prefs, clients, family, statuses[i]))); + await nextChange(signal); + } + }, + }); +}; diff --git a/extensions/databases/db.ts b/extensions/databases/db.ts new file mode 100644 index 0000000..8a78d6d --- /dev/null +++ b/extensions/databases/db.ts @@ -0,0 +1,275 @@ +// Reading the connection files, looking up passwords, detecting clients and +// checking servers. Shared by both commands. + +import { + action, + clipboard, + close, + copy, + exec, + field, + form, + type FormSpec, + Icons, + type Item, + pop, + storage, + toast, +} from "@backslash/api"; +import { + type Client, + clientCommand, + CLIENTS, + type Connection, + type DbType, + DEFAULT_PORTS, + type Family, + installCommand, + interpretStatus, + matchPgpass, + myCnfConnections, + osFamily, + parseMyCnf, + parsePasswords, + parsePgpass, + parsePgService, + pgpassConnections, + type PgpassEntry, + type SavedConnection, + savedConnections, + serviceConnections, + sortConnections, + type Status, + statusCommand, + toSaved, + TYPE_NAMES, + TYPES, + upsertSaved, +} from "./lib.ts"; +import { runInTerminal } from "./terminal.ts"; + +export interface Prefs { + passwords?: string; + terminal?: string; +} + +export const HOME = Deno.env.get("HOME") ?? ""; + +const readText = (path: string) => Deno.readTextFile(path).catch(() => ""); + +export interface Sources { + connections: Connection[]; + pgpass: PgpassEntry[]; + /** Passwords found in the user's own files, by connection id. */ + filePasswords: Map; + /** Files that exist, for the empty state. */ + files: string[]; +} + +export async function loadSources(): Promise { + const [pgpassText, serviceText, myCnfText, saved] = await Promise.all([ + readText(`${HOME}/.pgpass`), + readText(`${HOME}/.pg_service.conf`), + readText(`${HOME}/.my.cnf`), + storage.get("connections").catch(() => undefined), + ]); + const pgpass = parsePgpass(pgpassText); + const services = parsePgService(serviceText); + const groups = parseMyCnf(myCnfText); + const filePasswords = new Map(); + for (const s of services) if (s.password) filePasswords.set(`service:${s.name}`, s.password); + for (const g of groups) if (g.password) filePasswords.set(`mycnf:${g.suffix}`, g.password); + const connections = sortConnections([ + ...savedConnections(saved), + ...serviceConnections(services), + ...pgpassConnections(pgpass), + ...myCnfConnections(groups), + ]); + const files = [ + pgpassText && "~/.pgpass", + serviceText && "~/.pg_service.conf", + myCnfText && "~/.my.cnf", + ].filter(Boolean) as string[]; + return { connections, pgpass, filePasswords, files }; +} + +/** Where the password comes from, and the password itself, if any. */ +export function passwordOf( + c: Connection, + src: Sources, + prefs: Prefs, +): { password?: string; origin?: string } { + if (c.source === "saved") { + const pref = parsePasswords(prefs.passwords).get(c.name); + if (pref !== undefined) return { password: pref, origin: "Passwords preference" }; + } + const own = src.filePasswords.get(c.id); + if (own !== undefined) return { password: own, origin: c.source === "mycnf" ? "~/.my.cnf" : "~/.pg_service.conf" }; + if (c.type === "postgres" && !c.service) { + const hit = matchPgpass(src.pgpass, c); + if (hit) return { password: hit.password, origin: "~/.pgpass" }; + } + return {}; +} + +/** Whether the client reads the password from its own file, so none has to be passed. */ +export function readsOwnPassword(origin?: string): boolean { + return origin === "~/.pgpass" || origin === "~/.pg_service.conf" || origin === "~/.my.cnf"; +} + +// --- Clients ------------------------------------------------------------------ + +let installed: Promise> | undefined; + +/** Clients found on this machine (tried once per process). */ +export function installedClients(): Promise> { + installed ??= (async () => { + const all = Object.values(CLIENTS).flat(); + const found = await Promise.all(all.map(async (client) => { + try { + await new Deno.Command(client, { args: ["--version"], stdin: "null", stdout: "null", stderr: "null" }) + .output(); + return client; + } catch { + return undefined; + } + })); + return new Set(found.filter((c): c is Client => !!c)); + })(); + return installed; +} + +let family: Promise | undefined; +export function distro(): Promise { + family ??= readText("/etc/os-release").then(osFamily); + return family; +} + +export function openIn(c: Connection, client: Client, src: Sources, prefs: Prefs): string { + const { password, origin } = passwordOf(c, src, prefs); + const inv = clientCommand(c, client, readsOwnPassword(origin) ? undefined : password); + return runInTerminal(inv.argv, prefs.terminal || undefined, inv.env); +} + +// --- Status ------------------------------------------------------------------- + +/** Asks the server (or the file) whether it answers, in at most 5 s. */ +export async function checkStatus(c: Connection, signal?: AbortSignal): Promise { + const argv = statusCommand(c); + if (!argv) return { health: "unknown", text: "No file" }; + const timeout = AbortSignal.timeout(5000); + try { + const out = await exec(argv[0], argv.slice(1), { signal: signal ? AbortSignal.any([signal, timeout]) : timeout }); + return interpretStatus(c.type, out); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return { health: "missing", text: `${argv[0]} not installed` }; + } + if (timeout.aborted) return { health: "down", text: "No answer in 5 s" }; + return interpretStatus(c.type, undefined, err); + } +} + +// --- Saved connections ---------------------------------------------------------- + +export async function savedList(): Promise { + const all = await storage.get("connections").catch(() => undefined); + return Array.isArray(all) ? all as unknown as SavedConnection[] : []; +} + +export async function saveConnection(next: SavedConnection, previous?: string) { + await storage.set("connections", JSON.parse(JSON.stringify(upsertSaved(await savedList(), next, previous)))); + notifyChanged(); +} + +export async function deleteConnection(name: string) { + await storage.set("connections", JSON.parse(JSON.stringify((await savedList()).filter((s) => s.name !== name)))); + notifyChanged(); +} + +export function connectionForm( + options: { connection?: SavedConnection; type?: DbType; standalone?: boolean; name?: string } = {}, +): FormSpec { + const c = options.connection; + return form({ + title: c ? `Edit “${c.name}”` : "New Database Connection", + submitTitle: c ? "Save Connection" : "Add Connection", + fields: [ + field.text("name", "Name", { + required: true, + placeholder: "prod-db, local cache…", + value: c?.name ?? options.name ?? "", + }), + field.dropdown("type", "Type", TYPES.map((t) => ({ value: t, title: TYPE_NAMES[t] })), { + value: c?.type ?? options.type ?? "postgres", + }), + field.text("host", "Host", { placeholder: "localhost", value: c?.host ?? "" }), + field.text("port", "Port", { + placeholder: "5432, 3306, 6379 (default for the type)", + value: c?.port && c.port !== DEFAULT_PORTS[c.type] ? String(c.port) : "", + }), + field.text("database", "Database", { + placeholder: "Database name; Redis: db number; SQLite: file path", + value: c?.type === "sqlite" ? c.path ?? "" : c?.database ?? "", + }), + field.text("user", "User", { placeholder: "Optional", value: c?.user ?? "" }), + ], + async submit(values) { + const next = toSaved(values as Record, HOME); + await saveConnection(next, c?.name); + await toast(c ? "Connection saved" : `Added ${next.name}`, { + style: "success", + message: next.type === "sqlite" ? undefined : "Passwords go in the Passwords preference or ~/.pgpass", + }); + return options.standalone ? close(`Added ${next.name}`) : pop(); + }, + }); +} + +// --- Live reload ------------------------------------------------------------------ + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Resolves on the next change, or when `signal` aborts. */ +export function nextChange(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); +} + +// --- Helpers for items ----------------------------------------------------------- + +export function copySecret(text: string, title: string, o: { shortcut?: string; section?: string } = {}) { + return action(title, async () => { + await clipboard.write(text, { concealed: true }); + return close("Copied"); + }, { icon: Icons.key, ...o }); +} + +export async function missingClientItem(type: DbType): Promise { + const cmd = installCommand(type, await distro()); + return { + id: `missing:${type}`, + title: `No ${TYPE_NAMES[type]} client installed`, + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + section: "Setup", + detail: { + markdown: [ + `Opening ${TYPE_NAMES[type]} connections needs **${CLIENTS[type].join("**, **")}**.`, + "", + "```sh", + cmd, + "```", + ].join("\n"), + }, + actions: [copy(cmd, "Copy Install Command", { icon: Icons.terminal })], + }; +} diff --git a/extensions/databases/lib.ts b/extensions/databases/lib.ts new file mode 100644 index 0000000..cf93c72 --- /dev/null +++ b/extensions/databases/lib.ts @@ -0,0 +1,583 @@ +// Pure logic of the Databases extension: config file parsers, URLs, client +// command lines and status interpretation. No I/O here. + +export type DbType = "postgres" | "mysql" | "sqlite" | "redis"; +export type Source = "saved" | "pgpass" | "service" | "mycnf"; + +export const TYPES: DbType[] = ["postgres", "mysql", "sqlite", "redis"]; + +export const TYPE_NAMES: Record = { + postgres: "PostgreSQL", + mysql: "MySQL / MariaDB", + sqlite: "SQLite", + redis: "Redis", +}; + +export const TYPE_COLORS = { postgres: "blue", mysql: "orange", sqlite: "teal", redis: "red" } as const; + +export const DEFAULT_PORTS: Record = { + postgres: 5432, + mysql: 3306, + sqlite: undefined, + redis: 6379, +}; + +export const SOURCE_NAMES: Record = { + saved: "Saved", + pgpass: "~/.pgpass", + service: "~/.pg_service.conf", + mycnf: "~/.my.cnf", +}; + +/** A connection as listed. Passwords are never part of it: they are looked up when needed. */ +export interface Connection { + id: string; + name: string; + type: DbType; + source: Source; + host?: string; + port?: number; + database?: string; + user?: string; + /** SQLite database file. */ + path?: string; + /** pg_service.conf section. */ + service?: string; + /** my.cnf option group suffix (`_prod` for `[client_prod]`, "" for `[client]`). */ + group?: string; + /** Unix socket (my.cnf). */ + socket?: string; +} + +/** What is kept in storage for a saved connection (no password, ever). */ +export interface SavedConnection { + name: string; + type: DbType; + host?: string; + port?: number; + database?: string; + user?: string; + path?: string; +} + +// --- ~/.pgpass --------------------------------------------------------------- + +export interface PgpassEntry { + host: string; + port: string; + database: string; + user: string; + password: string; +} + +/** Splits a pgpass line on unescaped colons (`\:` and `\\` are escapes). */ +function splitPgpass(line: string): string[] { + const fields: string[] = []; + let cur = ""; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\" && i + 1 < line.length) cur += line[++i]; + else if (c === ":") { + fields.push(cur); + cur = ""; + } else cur += c; + } + fields.push(cur); + return fields; +} + +export function parsePgpass(text: string): PgpassEntry[] { + const out: PgpassEntry[] = []; + for (const raw of text.split("\n")) { + const line = raw.replace(/\r$/, ""); + if (!line.trim() || line.trimStart().startsWith("#")) continue; + const f = splitPgpass(line); + if (f.length < 5) continue; + // A password may itself contain unescaped colons in sloppy files: keep the rest. + out.push({ host: f[0], port: f[1], database: f[2], user: f[3], password: f.slice(4).join(":") }); + } + return out; +} + +/** The first pgpass entry matching a connection, as libpq picks it. */ +export function matchPgpass( + entries: PgpassEntry[], + c: { host?: string; port?: number; database?: string; user?: string }, +): PgpassEntry | undefined { + const host = c.host || "localhost"; + const port = String(c.port ?? 5432); + const user = c.user ?? ""; + const db = c.database || user; + const ok = (pattern: string, value: string) => pattern === "*" || pattern === value; + return entries.find((e) => + ok(e.host, host) && ok(e.port, port) && ok(e.database, db) && (e.user === "*" || !user || e.user === user) + ); +} + +const portOf = (s: string | undefined) => { + const n = Number(s); + return s && Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined; +}; + +/** One connection per pgpass line with a concrete host (wildcard hosts are credential templates). */ +export function pgpassConnections(entries: PgpassEntry[]): Connection[] { + const seen = new Set(); + const out: Connection[] = []; + for (const e of entries) { + if (e.host === "*") continue; + const database = e.database === "*" ? undefined : e.database; + const user = e.user === "*" ? undefined : e.user; + const port = e.port === "*" ? undefined : portOf(e.port); + const id = `pgpass:${user ?? ""}@${e.host}:${port ?? ""}/${database ?? ""}`; + if (seen.has(id)) continue; + seen.add(id); + out.push({ + id, + name: `${database ?? "any database"} on ${e.host}`, + type: "postgres", + source: "pgpass", + host: e.host, + port, + database, + user, + }); + } + return out; +} + +// --- INI files (pg_service.conf, my.cnf) ----------------------------------------- + +export type Ini = { section: string; values: Record }[]; + +export function parseIni(text: string): Ini { + const out: Ini = []; + let cur: Ini[number] | undefined; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";") || line.startsWith("!")) continue; + const head = /^\[([^\]]+)\]$/.exec(line); + if (head) { + cur = { section: head[1].trim(), values: {} }; + out.push(cur); + continue; + } + if (!cur) continue; + const eq = line.indexOf("="); + const key = (eq < 0 ? line : line.slice(0, eq)).trim().toLowerCase().replace(/-/g, "_"); + let value = eq < 0 ? "" : line.slice(eq + 1).trim(); + if (/^(["']).*\1$/.test(value) && value.length >= 2) value = value.slice(1, -1); + cur.values[key] = value; + } + return out; +} + +export interface PgService { + name: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; +} + +export function parsePgService(text: string): PgService[] { + return parseIni(text).map(({ section, values: v }) => ({ + name: section, + host: v.host || v.hostaddr || undefined, + port: portOf(v.port), + database: v.dbname || undefined, + user: v.user || undefined, + password: v.password || undefined, + })); +} + +export function serviceConnections(services: PgService[]): Connection[] { + return services.map((s) => ({ + id: `service:${s.name}`, + name: s.name, + type: "postgres" as const, + source: "service" as const, + service: s.name, + host: s.host, + port: s.port, + database: s.database, + user: s.user, + })); +} + +export interface MyCnfGroup { + /** "" for `[client]` / `[mysql]`, else the suffix (`_prod`). */ + suffix: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; + socket?: string; +} + +/** + * Client option groups of a my.cnf: `[client]` and `[mysql]` make the default + * connection, `[client_x]` / `[mysql_x]` are read by `--defaults-group-suffix=_x`. + */ +export function parseMyCnf(text: string): MyCnfGroup[] { + const groups = new Map>(); + for (const { section, values } of parseIni(text)) { + const m = /^(client|mysql|mariadb-client|mariadb)(.*)$/i.exec(section); + if (!m) continue; + let suffix = m[2]; + if (m[1].toLowerCase().startsWith("mariadb") && suffix.startsWith("-client")) suffix = suffix.slice(7); + const merged = { ...(groups.get(suffix) ?? {}), ...values }; + groups.set(suffix, merged); + } + const out: MyCnfGroup[] = []; + for (const [suffix, v] of groups) { + if (!v.host && !v.user && !v.database && !v.socket) continue; + out.push({ + suffix, + host: v.host || undefined, + port: portOf(v.port), + database: v.database || undefined, + user: v.user || undefined, + password: v.password ?? v.pass ?? undefined, + socket: v.socket || undefined, + }); + } + return out; +} + +export function myCnfConnections(groups: MyCnfGroup[]): Connection[] { + return groups.map((g) => ({ + id: `mycnf:${g.suffix}`, + name: g.suffix ? g.suffix.replace(/^[_-]/, "") : `${g.user ?? "default"}@${g.host ?? "localhost"}`, + type: "mysql" as const, + source: "mycnf" as const, + group: g.suffix, + host: g.host, + port: g.port, + database: g.database, + user: g.user, + socket: g.socket, + })); +} + +// --- Saved connections and their secret preference ------------------------------ + +export function savedConnections(saved: unknown): Connection[] { + if (!Array.isArray(saved)) return []; + return saved.filter((s): s is SavedConnection => + !!s && typeof s === "object" && typeof s.name === "string" && TYPES.includes(s.type) + ).map((s) => ({ + id: `saved:${s.name}`, + name: s.name, + type: s.type, + source: "saved" as const, + host: s.type === "sqlite" ? undefined : s.host, + port: s.type === "sqlite" ? undefined : s.port, + database: s.type === "sqlite" ? undefined : s.database, + user: s.type === "sqlite" ? undefined : s.user, + path: s.type === "sqlite" ? s.path : undefined, + })); +} + +/** + * The "Passwords" secret preference: `name=password` pairs separated by `;` + * or new lines. `\;` and `\\` escape inside a password. + */ +export function parsePasswords(text: unknown): Map { + const out = new Map(); + const s = String(text ?? ""); + let cur = ""; + const flush = () => { + const eq = cur.indexOf("="); + if (eq > 0) out.set(cur.slice(0, eq).trim(), cur.slice(eq + 1)); + cur = ""; + }; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === "\\" && i + 1 < s.length) cur += s[++i]; + else if (c === ";" || c === "\n") flush(); + else cur += c; + } + flush(); + return out; +} + +export interface FormInput { + name?: string; + type?: string; + host?: string; + port?: string; + database?: string; + user?: string; +} + +/** Validates the form; for SQLite, `database` is the file path. */ +export function toSaved(v: FormInput, home: string): SavedConnection { + const name = (v.name ?? "").trim(); + if (!name) throw new Error("Give the connection a name"); + const type = (v.type ?? "postgres") as DbType; + if (!TYPES.includes(type)) throw new Error(`Unknown type ${v.type}`); + if (type === "sqlite") { + let path = (v.database ?? "").trim(); + if (!path) throw new Error("Enter the path of the SQLite file in Database"); + if (path.startsWith("~/")) path = home + path.slice(1); + if (!path.startsWith("/")) throw new Error("Use an absolute path (or ~/…) for the SQLite file"); + return { name, type, path }; + } + const portText = (v.port ?? "").trim(); + const port = portText ? portOf(portText) : DEFAULT_PORTS[type]; + if (port === undefined) throw new Error(`Port must be a number from 1 to 65535, not “${portText}”`); + const out: SavedConnection = { name, type, host: (v.host ?? "").trim() || "localhost", port }; + const database = (v.database ?? "").trim(); + const user = (v.user ?? "").trim(); + if (database) out.database = database; + if (user) out.user = user; + return out; +} + +/** Replaces (by name, or `previous` when renaming) or adds a saved connection. */ +export function upsertSaved(all: SavedConnection[], next: SavedConnection, previous?: string): SavedConnection[] { + const rest = all.filter((s) => s.name !== (previous ?? next.name)); + if (rest.some((s) => s.name === next.name)) throw new Error(`A connection named “${next.name}” already exists`); + return [...rest, next].sort((a, b) => a.name.localeCompare(b.name)); +} + +// --- URLs and command lines ------------------------------------------------------- + +const hostPart = (host: string) => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +/** A connection URL; the password is masked unless `reveal`. */ +export function connectionUrl(c: Connection, password?: string, reveal = false): string { + if (c.type === "sqlite") return `sqlite://${c.path ?? ""}`; + if (c.type === "postgres" && c.service) return `postgresql://?service=${encodeURIComponent(c.service)}`; + const scheme = { postgres: "postgresql", mysql: "mysql", redis: "redis" }[c.type]; + const pw = password ? `:${reveal ? encodeURIComponent(password) : "****"}` : ""; + const user = c.user ? encodeURIComponent(c.user) : ""; + const auth = user || pw ? `${user}${pw}@` : ""; + const port = c.port ? `:${c.port}` : ""; + const db = c.database ? `/${encodeURIComponent(c.database)}` : ""; + return `${scheme}://${auth}${hostPart(c.host ?? "localhost")}${port}${db}`; +} + +export type Client = "psql" | "pgcli" | "mysql" | "mariadb" | "mycli" | "sqlite3" | "litecli" | "redis-cli"; + +export const CLIENTS: Record = { + postgres: ["psql", "pgcli"], + mysql: ["mysql", "mariadb", "mycli"], + sqlite: ["sqlite3", "litecli"], + redis: ["redis-cli"], +}; + +/** Environment variable each client reads a password from (kept out of argv). */ +const PASSWORD_ENV: Partial> = { + psql: "PGPASSWORD", + pgcli: "PGPASSWORD", + mysql: "MYSQL_PWD", + mariadb: "MYSQL_PWD", + mycli: "MYSQL_PWD", + "redis-cli": "REDISCLI_AUTH", +}; + +export interface Invocation { + argv: string[]; + env: Record; +} + +/** + * How to open `c` with `client`. Files the clients read themselves + * (pgpass, pg_service.conf, my.cnf) need no password here. + */ +export function clientCommand(c: Connection, client: Client, password?: string): Invocation { + const env: Record = {}; + const envName = PASSWORD_ENV[client]; + if (password && envName) env[envName] = password; + const argv: string[] = [client]; + switch (c.type) { + case "postgres": + if (c.service) { + argv.push(`service=${c.service}`); + break; + } + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-p", String(c.port)); + if (c.user) argv.push("-U", c.user); + if (c.database) argv.push("-d", c.database); + break; + case "mysql": + if (c.source === "mycnf") { + if (c.group) argv.push(`--defaults-group-suffix=${c.group}`); + break; + } + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-P", String(c.port)); + if (c.user) argv.push("-u", c.user); + if (c.database) argv.push(c.database); + break; + case "sqlite": + argv.push(c.path ?? ""); + break; + case "redis": + if (c.host) argv.push("-h", c.host); + if (c.port) argv.push("-p", String(c.port)); + if (c.user) argv.push("--user", c.user); + if (c.database && /^\d+$/.test(c.database)) argv.push("-n", c.database); + break; + } + return { argv, env }; +} + +// --- Status ----------------------------------------------------------------------- + +export type Health = "up" | "auth" | "rejecting" | "down" | "missing" | "unknown"; + +export interface Status { + health: Health; + text: string; +} + +export const HEALTH_COLORS = { + up: "green", + auth: "green", + rejecting: "orange", + down: "red", + missing: "gray", + unknown: "gray", +} as const; + +/** The program and arguments that check whether the server answers. */ +export function statusCommand(c: Connection): string[] | undefined { + switch (c.type) { + case "postgres": + if (c.service) return ["pg_isready", "-t", "3", "-d", `service=${c.service}`]; + return [ + "pg_isready", + "-t", + "3", + "-h", + c.host ?? "localhost", + "-p", + String(c.port ?? 5432), + ...(c.user ? ["-U", c.user] : []), + ...(c.database ? ["-d", c.database] : []), + ]; + case "mysql": + return [ + "mysqladmin", + ...(c.source === "mycnf" && c.group ? [`--defaults-group-suffix=${c.group}`] : []), + "--connect-timeout=3", + ...(c.source !== "mycnf" || c.host ? ["-h", c.host ?? "localhost"] : []), + ...(c.port ? ["-P", String(c.port)] : []), + ...(c.source !== "mycnf" && c.user ? ["-u", c.user] : []), + "ping", + ]; + case "redis": + return ["redis-cli", "-h", c.host ?? "localhost", "-p", String(c.port ?? 6379), "ping"]; + case "sqlite": + return c.path + ? ["sqlite3", "-readonly", c.path, "select count(*) from sqlite_master where type = 'table'"] + : undefined; + } +} + +/** `exec` errors read "prog exited with N: message". */ +export function exitCode(err: unknown): number | undefined { + const m = /exited with (\d+)/.exec(err instanceof Error ? err.message : String(err)); + return m ? Number(m[1]) : undefined; +} + +const errorText = (err: unknown) => + (err instanceof Error ? err.message : String(err)).replace(/^[\w-]+ exited with \d+: /, ""); + +/** Reads the result of {@link statusCommand}: its output, or the error it threw. */ +export function interpretStatus(type: DbType, out: string | undefined, err?: unknown): Status { + if (err !== undefined) { + const text = errorText(err); + if (type === "postgres") { + const code = exitCode(err); + if (code === 1) return { health: "rejecting", text: "Rejecting connections" }; + if (code === 2) return { health: "down", text: "No response" }; + if (code === 3) return { health: "unknown", text: text || "Invalid parameters" }; + } + if (type === "mysql" && /Access denied/i.test(text)) return { health: "auth", text: "Up (access denied)" }; + if (type === "sqlite") { + if (/unable to open|no such file/i.test(text)) return { health: "down", text: "File not found" }; + if (/not a database|encrypted/i.test(text)) return { health: "rejecting", text: "Not a SQLite database" }; + } + if (/refused|can't connect|could not connect|timed out|timeout|unknown host|name or service/i.test(text)) { + return { health: "down", text: "Unreachable" }; + } + return { health: "unknown", text: text.split("\n")[0].slice(0, 80) || "Unknown" }; + } + const o = (out ?? "").trim(); + switch (type) { + case "postgres": + return { health: "up", text: "Accepting connections" }; + case "mysql": + return /alive/i.test(o) ? { health: "up", text: "Alive" } : { health: "unknown", text: o || "Unknown" }; + case "redis": + if (/^PONG$/i.test(o)) return { health: "up", text: "PONG" }; + if (/NOAUTH|WRONGPASS|NOPERM/i.test(o)) return { health: "auth", text: "Up (needs auth)" }; + if (/LOADING/i.test(o)) return { health: "rejecting", text: "Loading dataset" }; + if (/Could not connect|refused/i.test(o)) return { health: "down", text: "Unreachable" }; + return { health: "unknown", text: o.slice(0, 80) || "No answer" }; + case "sqlite": { + const n = Number(o); + return Number.isFinite(n) + ? { health: "up", text: `${n} table${n === 1 ? "" : "s"}` } + : { health: "unknown", text: o || "Unknown" }; + } + } +} + +// --- Display ------------------------------------------------------------------- + +/** `user@host:port/db`, `service=prod` or the file path. */ +export function describe(c: Connection, home = ""): string { + if (c.type === "sqlite") { + return home && c.path?.startsWith(home + "/") ? `~${c.path.slice(home.length)}` : c.path ?? ""; + } + if (c.service) return `service=${c.service}`; + if (c.socket && !c.host) return `${c.user ? `${c.user}@` : ""}${c.socket}`; + const port = c.port && c.port !== DEFAULT_PORTS[c.type] ? `:${c.port}` : ""; + return `${c.user ? `${c.user}@` : ""}${c.host ?? "localhost"}${port}${c.database ? `/${c.database}` : ""}`; +} + +export function sortConnections(all: Connection[]): Connection[] { + const order: Source[] = ["saved", "service", "pgpass", "mycnf"]; + return [...all].sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source) || a.name.localeCompare(b.name)); +} + +// --- Installing clients ------------------------------------------------------------ + +export type Family = "debian" | "fedora" | "arch" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const PACKAGES: Record> = { + postgres: { debian: "postgresql-client", fedora: "postgresql", arch: "postgresql", suse: "postgresql" }, + mysql: { debian: "mariadb-client", fedora: "mariadb", arch: "mariadb-clients", suse: "mariadb-client" }, + sqlite: { debian: "sqlite3", fedora: "sqlite", arch: "sqlite", suse: "sqlite3" }, + redis: { debian: "redis-tools", fedora: "redis", arch: "redis", suse: "redis" }, +}; + +const MANAGERS: Record = { + debian: "sudo apt install", + fedora: "sudo dnf install", + arch: "sudo pacman -S", + suse: "sudo zypper install", +}; + +/** The install command for the client of `type`, for this distribution (Debian's when unknown). */ +export function installCommand(type: DbType, family: Family | undefined): string { + const f = family ?? "debian"; + return `${MANAGERS[f]} ${PACKAGES[type][f]}`; +} diff --git a/extensions/databases/lib_test.ts b/extensions/databases/lib_test.ts new file mode 100644 index 0000000..7bce003 --- /dev/null +++ b/extensions/databases/lib_test.ts @@ -0,0 +1,313 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + clientCommand, + type Connection, + connectionUrl, + describe, + installCommand, + interpretStatus, + matchPgpass, + myCnfConnections, + osFamily, + parseMyCnf, + parsePasswords, + parsePgpass, + parsePgService, + pgpassConnections, + savedConnections, + serviceConnections, + sortConnections, + statusCommand, + toSaved, + upsertSaved, +} from "./lib.ts"; +import { holdOpen, shellLine, terminalArgs } from "./terminal.ts"; + +const PGPASS = [ + "# host:port:database:username:password", + "db.prod.acme.io:5432:shop:app:s3cr\\:et", + "localhost:5433:*:postgres:local", + "*:*:*:readonly:ro-pass", + "db.prod.acme.io:5432:shop:app:duplicate", + "broken line", + "", +].join("\n"); + +Deno.test("pgpass: escapes, wildcards and comments", () => { + const entries = parsePgpass(PGPASS); + assertEquals(entries.length, 4); + assertEquals(entries[0], { + host: "db.prod.acme.io", + port: "5432", + database: "shop", + user: "app", + password: "s3cr:et", + }); + assertEquals(entries[2].host, "*"); +}); + +Deno.test("pgpass: connections skip wildcard hosts and duplicates", () => { + const conns = pgpassConnections(parsePgpass(PGPASS)); + assertEquals(conns.map((c) => c.name), ["shop on db.prod.acme.io", "any database on localhost"]); + assertEquals(conns[1].port, 5433); + assertEquals(conns[1].database, undefined); + assertEquals(conns[1].user, "postgres"); +}); + +Deno.test("pgpass: matching follows libpq (first match, wildcards, db defaults to user)", () => { + const entries = parsePgpass(PGPASS); + assertEquals( + matchPgpass(entries, { host: "db.prod.acme.io", port: 5432, database: "shop", user: "app" })?.password, + "s3cr:et", + ); + assertEquals(matchPgpass(entries, { host: "localhost", port: 5433, user: "postgres" })?.password, "local"); + assertEquals(matchPgpass(entries, { host: "other", database: "x", user: "readonly" })?.password, "ro-pass"); + assertEquals(matchPgpass(entries, { host: "other", database: "x", user: "nobody" }), undefined); +}); + +Deno.test("pg_service.conf", () => { + const services = parsePgService(` +# comment +[prod] +host=db.prod.acme.io +port=6432 +dbname=shop +user=app +password=hunter2 + +[local] +dbname = scratch +`); + assertEquals(services[0], { + name: "prod", + host: "db.prod.acme.io", + port: 6432, + database: "shop", + user: "app", + password: "hunter2", + }); + const conns = serviceConnections(services); + assertEquals(conns[1].id, "service:local"); + assertEquals(clientCommand(conns[0], "psql").argv, ["psql", "service=prod"]); + assertEquals(connectionUrl(conns[0]), "postgresql://?service=prod"); + assertEquals(statusCommand(conns[0]), ["pg_isready", "-t", "3", "-d", "service=prod"]); +}); + +Deno.test("my.cnf: [client] and suffixed groups", () => { + const groups = parseMyCnf(` +[mysqld] +datadir=/var/lib/mysql + +[client] +user=steven +password="p@ss word" + +[mysql] +database=blog + +[client_prod] +host=mysql.acme.io +port=3307 +user=deploy + +!includedir /etc/mysql/conf.d/ +`); + assertEquals(groups, [ + { + suffix: "", + host: undefined, + port: undefined, + database: "blog", + user: "steven", + password: "p@ss word", + socket: undefined, + }, + { + suffix: "_prod", + host: "mysql.acme.io", + port: 3307, + database: undefined, + user: "deploy", + password: undefined, + socket: undefined, + }, + ]); + const conns = myCnfConnections(groups); + assertEquals(conns.map((c) => c.name), ["steven@localhost", "prod"]); + // The client reads the group itself. + assertEquals(clientCommand(conns[1], "mysql").argv, ["mysql", "--defaults-group-suffix=_prod"]); + assertEquals(clientCommand(conns[0], "mariadb").argv, ["mariadb"]); + assertEquals(statusCommand(conns[1]), [ + "mysqladmin", + "--defaults-group-suffix=_prod", + "--connect-timeout=3", + "-h", + "mysql.acme.io", + "-P", + "3307", + "ping", + ]); +}); + +Deno.test("passwords preference", () => { + const p = parsePasswords("prod-db=s3cret;cache=a\\;b\nlocal = x=y"); + assertEquals(p.get("prod-db"), "s3cret"); + assertEquals(p.get("cache"), "a;b"); + assertEquals(p.get("local"), " x=y"); + assertEquals(parsePasswords(undefined).size, 0); +}); + +Deno.test("form values become saved connections", () => { + assertEquals( + toSaved({ name: " prod ", type: "postgres", host: "", port: "", database: "shop", user: "app" }, "/home/u"), + { + name: "prod", + type: "postgres", + host: "localhost", + port: 5432, + database: "shop", + user: "app", + }, + ); + assertEquals(toSaved({ name: "notes", type: "sqlite", database: "~/data/notes.db" }, "/home/u"), { + name: "notes", + type: "sqlite", + path: "/home/u/data/notes.db", + }); + assertEquals(toSaved({ name: "cache", type: "redis", port: "6380" }, "/h").port, 6380); + assertThrows(() => toSaved({ name: "", type: "redis" }, "/h"), Error, "name"); + assertThrows(() => toSaved({ name: "x", type: "mysql", port: "99999" }, "/h"), Error, "Port"); + assertThrows(() => toSaved({ name: "x", type: "sqlite", database: "rel.db" }, "/h"), Error, "absolute"); +}); + +Deno.test("saved connections: upsert, rename, duplicates", () => { + const a = { name: "a", type: "redis" as const }; + const b = { name: "b", type: "redis" as const }; + assertEquals(upsertSaved([b], a).map((s) => s.name), ["a", "b"]); + assertEquals(upsertSaved([a, b], { ...a, name: "c" }, "a").map((s) => s.name), ["b", "c"]); + assertThrows(() => upsertSaved([a, b], { ...a, name: "b" }, "a"), Error, "already exists"); + // Garbage in storage is ignored. + assertEquals(savedConnections([a, { name: 1 }, null, { name: "z", type: "oracle" }]).length, 1); + assertEquals(savedConnections("nope"), []); +}); + +const pg: Connection = { + id: "saved:prod", + name: "prod", + type: "postgres", + source: "saved", + host: "db.acme.io", + port: 5432, + database: "shop", + user: "app", +}; + +Deno.test("URLs mask the password unless revealed", () => { + assertEquals(connectionUrl(pg), "postgresql://app@db.acme.io:5432/shop"); + assertEquals(connectionUrl(pg, "p@ss"), "postgresql://app:****@db.acme.io:5432/shop"); + assertEquals(connectionUrl(pg, "p@ss", true), "postgresql://app:p%40ss@db.acme.io:5432/shop"); + const redis: Connection = { + id: "r", + name: "r", + type: "redis", + source: "saved", + host: "::1", + port: 6379, + database: "2", + }; + assertEquals(connectionUrl(redis, "pw", true), "redis://:pw@[::1]:6379/2"); + const lite: Connection = { id: "s", name: "s", type: "sqlite", source: "saved", path: "/home/u/app.db" }; + assertEquals(connectionUrl(lite), "sqlite:///home/u/app.db"); + assertEquals(describe(lite, "/home/u"), "~/app.db"); + assertEquals(describe(pg), "app@db.acme.io/shop"); +}); + +Deno.test("client commands keep passwords in the environment", () => { + assertEquals(clientCommand(pg, "psql", "pw"), { + argv: ["psql", "-h", "db.acme.io", "-p", "5432", "-U", "app", "-d", "shop"], + env: { PGPASSWORD: "pw" }, + }); + const my: Connection = { ...pg, type: "mysql", port: 3306 }; + assertEquals(clientCommand(my, "mycli", "pw"), { + argv: ["mycli", "-h", "db.acme.io", "-P", "3306", "-u", "app", "shop"], + env: { MYSQL_PWD: "pw" }, + }); + const redis: Connection = { + id: "r", + name: "r", + type: "redis", + source: "saved", + host: "cache", + port: 6380, + database: "1", + }; + assertEquals(clientCommand(redis, "redis-cli", "pw"), { + argv: ["redis-cli", "-h", "cache", "-p", "6380", "-n", "1"], + env: { REDISCLI_AUTH: "pw" }, + }); + assertEquals(clientCommand({ ...pg, type: "sqlite", path: "/x.db" }, "sqlite3").argv, ["sqlite3", "/x.db"]); +}); + +Deno.test("status: pg_isready exit codes", () => { + const err = (code: number, msg = "") => new Error(`pg_isready exited with ${code}: ${msg}`); + assertEquals(interpretStatus("postgres", "db:5432 - accepting connections").health, "up"); + assertEquals(interpretStatus("postgres", undefined, err(1, "rejecting")).health, "rejecting"); + assertEquals(interpretStatus("postgres", undefined, err(2, "db:5432 - no response")), { + health: "down", + text: "No response", + }); + assertEquals(interpretStatus("postgres", undefined, err(3)).health, "unknown"); +}); + +Deno.test("status: redis, mysql and sqlite", () => { + assertEquals(interpretStatus("redis", "PONG").health, "up"); + assertEquals(interpretStatus("redis", "NOAUTH Authentication required.").health, "auth"); + assertEquals( + interpretStatus( + "redis", + undefined, + new Error("redis-cli exited with 1: Could not connect to Redis at x:6379: Connection refused"), + ).health, + "down", + ); + assertEquals(interpretStatus("mysql", "mysqld is alive").health, "up"); + assertEquals( + interpretStatus("mysql", undefined, new Error("mysqladmin exited with 1: error: 'Access denied for user 'x'@'y''")) + .health, + "auth", + ); + assertEquals( + interpretStatus("mysql", undefined, new Error("mysqladmin exited with 1: Can't connect to server on 'x' (115)")) + .health, + "down", + ); + assertEquals(interpretStatus("sqlite", "12"), { health: "up", text: "12 tables" }); + assertEquals( + interpretStatus("sqlite", undefined, new Error('sqlite3 exited with 1: Error: unable to open database "/x.db"')) + .text, + "File not found", + ); +}); + +Deno.test("sorting puts saved connections first", () => { + const conns = sortConnections([ + { ...pg, id: "p", source: "pgpass", name: "a" }, + { ...pg, id: "s", source: "saved", name: "z" }, + { ...pg, id: "v", source: "service", name: "m" }, + ]); + assertEquals(conns.map((c) => c.id), ["s", "v", "p"]); +}); + +Deno.test("install commands per distribution", () => { + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian\n"), "debian"); + assertEquals(osFamily('ID="fedora"\n'), "fedora"); + assertEquals(installCommand("postgres", "debian"), "sudo apt install postgresql-client"); + assertEquals(installCommand("mysql", "arch"), "sudo pacman -S mariadb-clients"); + assertEquals(installCommand("redis", undefined), "sudo apt install redis-tools"); +}); + +Deno.test("terminal helpers", () => { + assertEquals(terminalArgs("gnome-terminal", ["psql"]), ["--", "psql"]); + assertEquals(shellLine(["psql", "-d", "my db"]), "psql -d 'my db'"); + assertEquals(holdOpen(["echo", "hi"])[2].startsWith("echo hi; printf"), true); +}); diff --git a/extensions/databases/terminal.ts b/extensions/databases/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/databases/terminal.ts @@ -0,0 +1,89 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + env, + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/default-apps/backslash.toml b/extensions/default-apps/backslash.toml new file mode 100644 index 0000000..2604c93 --- /dev/null +++ b/extensions/default-apps/backslash.toml @@ -0,0 +1,50 @@ +[extension] +id = "default-apps" +title = "Default Apps" +description = "See and change your default browser, mail app, file manager, viewers, players, editor and terminal" +version = "1.0.0" +author = "Backslash" +icon = "bs-apps" +license = "MIT" + +# xdg-mime and xdg-settings (xdg-utils) read and change the defaults, the way +# every desktop does. The applications folders are read to find the apps that +# can open each type. The terminal is set in ~/.config/xdg-terminals.list +# (xdg-terminal-exec). +[permissions] +run = ["xdg-mime", "xdg-settings"] +read = [ + "~/.local/share/applications", + "~/.local/share/flatpak/exports/share/applications", + "/usr/share/applications", + "/usr/local/share/applications", + "/var/lib/flatpak/exports/share/applications", + "/var/lib/snapd/desktop/applications", + "~/.nix-profile/share/applications", + "/run/current-system/sw/share/applications", + "~/.config/xdg-terminals.list", + "/etc/os-release", +] +write = ["~/.config/xdg-terminals.list"] +env = ["HOME", "XDG_DATA_HOME", "XDG_DATA_DIRS", "TERMINAL"] + +[[commands]] +name = "default-apps" +title = "Default Apps" +description = "The app that opens links, folders, pictures, videos, music, PDFs, text and terminals" +icon = "bs-apps" +keywords = [ + "default apps", + "applications par défaut", + "default browser", + "navigateur par défaut", + "open with", + "ouvrir avec", + "mime", + "file association", + "association de fichiers", + "xdg-mime", + "mimeapps", + "terminal", + "pdf", +] diff --git a/extensions/default-apps/commands/default-apps.ts b/extensions/default-apps/commands/default-apps.ts new file mode 100644 index 0000000..48807e5 --- /dev/null +++ b/extensions/default-apps/commands/default-apps.ts @@ -0,0 +1,258 @@ +import { + type Action, + action, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { + type App, + candidates, + CATEGORIES, + type Category, + type Current, + currentDefault, + desktopId, + execProgram, + fallbackName, + INSTALL_XDG_UTILS, + mergeApps, + osFamily, + parseApp, + parseQuery, + parseTerminalsList, + plural, + preferTerminal, + terminalFromEnv, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const CONFIG = `${HOME}/.config`; +const TERMINALS_LIST = `${CONFIG}/xdg-terminals.list`; + +/** Application folders in XDG order: the user's first. Only those in the manifest can be read. */ +function appDirs(): string[] { + const dataHome = Deno.env.get("XDG_DATA_HOME") || `${HOME}/.local/share`; + const dataDirs = (Deno.env.get("XDG_DATA_DIRS") || "/usr/local/share:/usr/share").split(":").filter(Boolean); + return [ + ...new Set([ + `${dataHome}/applications`, + `${HOME}/.local/share/flatpak/exports/share/applications`, + ...dataDirs.map((d) => `${d.replace(/\/$/, "")}/applications`), + "/var/lib/flatpak/exports/share/applications", + "/var/lib/snapd/desktop/applications", + `${HOME}/.nix-profile/share/applications`, + "/run/current-system/sw/share/applications", + ]), + ]; +} + +async function readApps(dir: string): Promise { + const apps: App[] = []; + const walk = async (path: string, rel: string, depth: number) => { + let entries: Deno.DirEntry[]; + try { + entries = await Array.fromAsync(Deno.readDir(path)); + } catch { + return; // Missing, or not in our read permission. + } + await Promise.all(entries.map(async (e) => { + const full = `${path}/${e.name}`; + const relative = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory && depth < 3) return walk(full, relative, depth + 1); + if (!e.name.endsWith(".desktop")) return; + const text = await Deno.readTextFile(full).catch(() => undefined); + const app = text === undefined ? undefined : parseApp(desktopId(relative), full, text); + if (app) apps.push(app); + })); + }; + await walk(dir, "", 0); + return apps; +} + +class NoXdgUtils extends Error {} + +async function xdg(program: "xdg-mime" | "xdg-settings", args: string[]): Promise { + try { + return await exec(program, args); + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NoXdgUtils(program); + throw err; + } +} + +async function terminalDefault(apps: App[]): Promise { + const text = await Deno.readTextFile(TERMINALS_LIST).catch(() => undefined); + const first = text && parseTerminalsList(text).find((id) => apps.some((a) => a.id === id)); + return first || terminalFromEnv(Deno.env.get("TERMINAL"), apps); +} + +interface State { + apps: Map; + current: Map; +} + +async function load(): Promise { + const apps = mergeApps(await Promise.all(appDirs().map(readApps))); + const mimes = [...new Set(CATEGORIES.flatMap((c) => c.mimes))]; + const byMime: Record = {}; + // xdg-mime is a shell script: ask in parallel. + await Promise.all(mimes.map(async (m) => { + byMime[m] = parseQuery( + await xdg("xdg-mime", ["query", "default", m]).catch((err) => { + if (err instanceof NoXdgUtils) throw err; + return ""; + }), + ); + })); + const browser = parseQuery(await xdg("xdg-settings", ["get", "default-web-browser"]).catch(() => "")); + const terminals = [...apps.values()].filter((a) => a.categories.includes("TerminalEmulator")); + const terminal = await terminalDefault(terminals); + const current = new Map(CATEGORIES.map((c) => { + const setting = c.id === "browser" ? browser : c.id === "terminal" ? terminal : undefined; + return [c.id, currentDefault(c, byMime, setting)]; + })); + return { apps, current }; +} + +async function setDefault(cat: Category, app: App) { + if (cat.id === "terminal") { + const text = await Deno.readTextFile(TERMINALS_LIST).catch(() => ""); + await Deno.writeTextFile(TERMINALS_LIST, preferTerminal(text, app.id)); + return; + } + if (cat.setting) { + // xdg-settings also sets the http/https handlers its desktop knows about. + await xdg("xdg-settings", ["set", cat.setting, app.id]).catch(() => {}); + } + // Every type of the category the app can open (all of them when it lists none of the extras). + const own = cat.mimes.filter((m) => app.mimes.includes(m)); + await xdg("xdg-mime", ["default", app.id, ...(own.length ? own : cat.mimes)]); +} + +const ICONS: Record = { + browser: [Icons.globe, "blue"], + mail: [Icons.mail, "teal"], + files: [Icons.folder, "yellow"], + images: [Icons.image, "green"], + video: [Icons.film, "red"], + music: [Icons.music, "pink"], + pdf: [Icons.file, "orange"], + text: [Icons.text, "purple"], + terminal: [Icons.terminal, "gray"], +}; + +function categoryItem(cat: Category, st: State): Item { + const cur = st.current.get(cat.id)!; + const app = cur.id ? st.apps.get(cur.id) : undefined; + const name = app?.name ?? (cur.id ? fallbackName(cur.id) : undefined); + const options = candidates(cat, st.apps.values()); + const [glyph, tint] = ICONS[cat.id]; + const nameOf = (id?: string) => (id ? st.apps.get(id)?.name ?? fallbackName(id) : "—"); + + const change = submenu( + "Change Default", + () => + options.map((o) => + action(o.name + (o.noDisplay ? " (hidden app)" : ""), async () => { + await setDefault(cat, o); + await toast(`${o.name} is your ${cat.title.toLowerCase()}`, { style: "success" }); + return refresh(); + }, { icon: o.id === cur.id ? Icons.check : Icons.apps }) + ), + { icon: Icons.apps, shortcut: "e" }, + ); + + // The detail's markdown has no tables: one bullet per type. + const types = cat.mimes.map((m) => { + const id = cur.byMime[m]; + const odd = id && id !== cur.id; + return `- \`${m}\` → ${id ? (odd ? `**${nameOf(id)}**` : nameOf(id)) : "*not set*"}`; + }).join("\n"); + + const actions: Action[] = [ + ...(options.length ? [change] : []), + ...(cur.mixed && app + ? [action(`Use ${app.name} for All Types`, async () => { + await setDefault(cat, app); + await toast(`${app.name} opens every ${cat.title.toLowerCase()} type`, { style: "success" }); + return refresh(); + }, { icon: Icons.check })] + : []), + ...(app ? [open(app.path, "Open Desktop File", { icon: Icons.file, section: "Files" })] : []), + cat.id === "terminal" + ? open(TERMINALS_LIST, "Open xdg-terminals.list", { icon: Icons.file, section: "Files" }) + : open(`${CONFIG}/mimeapps.list`, "Open mimeapps.list", { icon: Icons.file, section: "Files" }), + ...(cur.id ? [copy(cur.id, "Copy Desktop ID", { section: "Copy" })] : []), + ...(cat.mimes.length ? [copy(cat.mimes.join("\n"), "Copy MIME Types", { section: "Copy" })] : []), + ]; + + return { + id: cat.id, + title: cat.title, + subtitle: name ?? "Not set", + icon: { source: glyph, tint: name ? tint : "gray" }, + keywords: [...cat.mimes, cur.id ?? "", name ?? ""], + accessories: [ + ...(cur.mixed ? [{ tag: "Mixed", color: "orange" as const, tooltip: "Some types open with another app" }] : []), + ...(!name ? [{ tag: "Not set", color: "gray" as const }] : []), + { text: plural(options.length, "app"), tooltip: "Installed apps that can do it" }, + ], + detail: { + markdown: [cat.description, types, cur.mixed ? "Types in **bold** open with another app." : ""].filter(Boolean) + .join("\n\n"), + metadata: [ + meta.text("Default", name ?? "Not set", { icon: glyph, color: name ? undefined : "gray" }), + ...(cur.id ? [meta.text("Desktop ID", cur.id)] : []), + ...(app?.exec ? [meta.text("Program", execProgram(app.exec), { icon: Icons.terminal })] : []), + ...(cur.id && !app ? [meta.text("Installed", "No .desktop file found", { color: "orange" })] : []), + meta.separator(), + ...(cat.mimes.length ? [meta.tags("Types", cat.mimes.map((m) => m.replace("x-scheme-handler/", "")))] : []), + meta.tags( + "Can do it", + options.slice(0, 8).map((o) => ({ text: o.name, color: o.id === cur.id ? "blue" as const : undefined })), + ), + ], + }, + actions, + }; +} + +async function setupItem(): Promise { + const family = osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); + const commands = family ? [INSTALL_XDG_UTILS[family]] : Object.values(INSTALL_XDG_UTILS); + return { + id: "no-xdg-utils", + title: "xdg-utils isn't installed", + subtitle: "Default apps are read and changed with xdg-mime and xdg-settings", + icon: { source: Icons.package, tint: "gray" }, + detail: { markdown: ["Install xdg-utils:", "", "```", ...commands, "```"].join("\n") }, + actions: [copy(commands[0], "Copy Install Command")], + }; +} + +export default list({ + placeholder: "Filter default apps or MIME types…", + status: (items) => { + const mixed = items.filter((i) => i.accessories?.some((a) => a.tag === "Mixed")).length; + return `${items.length} ${items.length === 1 ? "category" : "categories"}${mixed ? ` · ${mixed} mixed` : ""}`; + }, + items: async () => { + let st: State; + try { + st = await load(); + } catch (err) { + if (err instanceof NoXdgUtils) return [await setupItem()]; + throw err; + } + return CATEGORIES.map((c) => categoryItem(c, st)); + }, +}); diff --git a/extensions/default-apps/lib.ts b/extensions/default-apps/lib.ts new file mode 100644 index 0000000..7daa94a --- /dev/null +++ b/extensions/default-apps/lib.ts @@ -0,0 +1,249 @@ +// Pure helpers for Default Apps: categories, .desktop files and MIME handlers. + +export interface Category { + id: string; + title: string; + /** Representative MIME types; the first one decides what "the default" is. */ + mimes: string[]; + /** Apps are found by a Categories= value instead of MIME types (terminals). */ + desktopCategory?: string; + /** Set with `xdg-settings set ` rather than `xdg-mime default`. */ + setting?: string; + description: string; +} + +export const CATEGORIES: Category[] = [ + { + id: "browser", + title: "Web Browser", + mimes: ["x-scheme-handler/http", "x-scheme-handler/https", "text/html", "application/xhtml+xml"], + setting: "default-web-browser", + description: "Opens links from every app.", + }, + { + id: "mail", + title: "Mail", + mimes: ["x-scheme-handler/mailto", "message/rfc822"], + description: "Opens mailto: links and saved emails.", + }, + { + id: "files", + title: "File Manager", + mimes: ["inode/directory"], + description: "Opens folders, e.g. “Show in folder” in apps.", + }, + { + id: "images", + title: "Image Viewer", + mimes: [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "image/bmp", + "image/tiff", + "image/avif", + ], + description: "Opens pictures.", + }, + { + id: "video", + title: "Video Player", + mimes: ["video/mp4", "video/x-matroska", "video/webm", "video/quicktime", "video/x-msvideo", "video/mpeg"], + description: "Plays video files.", + }, + { + id: "music", + title: "Music Player", + mimes: ["audio/mpeg", "audio/flac", "audio/ogg", "audio/x-wav", "audio/mp4", "audio/x-vorbis+ogg", "audio/opus"], + description: "Plays audio files.", + }, + { + id: "pdf", + title: "PDF Viewer", + mimes: ["application/pdf"], + description: "Opens PDF documents.", + }, + { + id: "text", + title: "Text Editor", + mimes: [ + "text/plain", + "text/markdown", + "application/x-shellscript", + "text/x-python", + "application/json", + "text/x-csrc", + "application/xml", + ], + description: "Opens plain text, code and config files.", + }, + { + id: "terminal", + title: "Terminal", + mimes: [], + desktopCategory: "TerminalEmulator", + description: + "Saved in ~/.config/xdg-terminals.list, read by xdg-terminal-exec, which desktops and apps use to open a terminal.", + }, +]; + +export interface App { + /** Desktop file id, e.g. `org.gnome.Nautilus.desktop`. */ + id: string; + name: string; + exec: string; + mimes: string[]; + categories: string[]; + noDisplay: boolean; + path: string; +} + +/** The [Desktop Entry] group of a .desktop file, translations left out. */ +export function parseDesktop(text: string): Record { + const out: Record = {}; + let inEntry = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("[")) { + inEntry = line === "[Desktop Entry]"; + continue; + } + const eq = line.indexOf("="); + if (!inEntry || eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (!key.includes("[")) out[key] = line.slice(eq + 1).trim(); + } + return out; +} + +const list = (v?: string) => (v ?? "").split(";").map((s) => s.trim()).filter(Boolean); + +/** An application entry, or undefined for hidden entries, links and directories. */ +export function parseApp(id: string, path: string, text: string): App | undefined { + const e = parseDesktop(text); + if ((e.Type ?? "Application") !== "Application" || e.Hidden?.toLowerCase() === "true" || !e.Name) return undefined; + return { + id, + name: e.Name, + exec: e.Exec ?? "", + mimes: list(e.MimeType), + categories: list(e.Categories), + noDisplay: e.NoDisplay?.toLowerCase() === "true", + path, + }; +} + +/** The desktop id of a file below an applications folder: `kde4/okular.desktop` → `kde4-okular.desktop`. */ +export function desktopId(relative: string): string { + return relative.replace(/^\/+/, "").replace(/\//g, "-"); +} + +/** First folder wins: the user's data folder shadows the system's. */ +export function mergeApps(byDir: App[][]): Map { + const out = new Map(); + for (const apps of byDir) for (const a of apps) if (!out.has(a.id)) out.set(a.id, a); + return out; +} + +/** Apps able to handle a category, the ones covering the most of its MIME types first. */ +export function candidates(cat: Category, apps: Iterable): App[] { + const scored: { app: App; score: number }[] = []; + for (const app of apps) { + const score = cat.desktopCategory + ? (app.categories.includes(cat.desktopCategory) ? 1 : 0) + : cat.mimes.filter((m) => app.mimes.includes(m)).length; + if (score > 0) scored.push({ app, score }); + } + return scored.sort((a, b) => + Number(a.app.noDisplay) - Number(b.app.noDisplay) || b.score - a.score || + a.app.name.localeCompare(b.app.name, undefined, { sensitivity: "base" }) + ).map((s) => s.app); +} + +export interface Current { + /** The app most of the category's types open with. */ + id?: string; + /** Types opening with something else. */ + mixed: boolean; + byMime: Record; +} + +/** The default of a category from each MIME type's default (`xdg-mime query default`). */ +export function currentDefault(cat: Category, byMime: Record, setting?: string): Current { + const ids = cat.mimes.map((m) => byMime[m]).filter((x): x is string => !!x); + const counts = new Map(); + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + const first = cat.mimes.length ? byMime[cat.mimes[0]] : undefined; + const id = setting || first || [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; + const mixed = cat.mimes.some((m) => byMime[m] && byMime[m] !== id); + return { id, mixed, byMime }; +} + +/** `xdg-mime query default` output: one desktop id, or nothing. Some setups list several, `;`-separated. */ +export function parseQuery(out: string): string | undefined { + const id = out.trim().split(/[;\n]/)[0]?.trim(); + return id && id.endsWith(".desktop") ? id : undefined; +} + +/** xdg-terminals.list: desktop ids (optionally `:action`), comments with #. */ +export function parseTerminalsList(text: string): string[] { + return text.split("\n").map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean).map((l) => l.split(":")[0]); +} + +/** The list with `id` first, the others kept after it. */ +export function preferTerminal(text: string, id: string): string { + const lines = text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")); + const rest = lines.filter((l) => l.split(":")[0] !== id); + return [id, ...rest].join("\n") + "\n"; +} + +/** The program of an Exec line: `env X=1 /usr/bin/foot --server %U` → `foot`. */ +export function execProgram(exec: string): string { + const words = exec.replace(/%[a-zA-Z]/g, "").trim().split(/\s+/); + let i = 0; + if (words[i] === "env") i++; + while (words[i] && /^\w+=/.test(words[i])) i++; + return (words[i] ?? "").replace(/^["']|["']$/g, "").split("/").pop() ?? ""; +} + +/** The terminal app matching $TERMINAL (a program name or path). */ +export function terminalFromEnv(env: string | undefined, apps: App[]): string | undefined { + if (!env) return undefined; + const prog = env.trim().split(/\s+/)[0].split("/").pop(); + return apps.find((a) => execProgram(a.exec) === prog || a.id === `${prog}.desktop`)?.id; +} + +/** A friendly name for an id with no installed .desktop file. */ +export function fallbackName(id: string): string { + const base = id.replace(/\.desktop$/, "").split(".").pop() ?? id; + return base.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +export const INSTALL_XDG_UTILS: Record = { + debian: "sudo apt install xdg-utils", + fedora: "sudo dnf install xdg-utils", + arch: "sudo pacman -S xdg-utils", + suse: "sudo zypper install xdg-utils", +}; diff --git a/extensions/default-apps/lib_test.ts b/extensions/default-apps/lib_test.ts new file mode 100644 index 0000000..2f879f6 --- /dev/null +++ b/extensions/default-apps/lib_test.ts @@ -0,0 +1,131 @@ +import { assert, assertEquals } from "@std/assert"; +import { + type App, + candidates, + CATEGORIES, + currentDefault, + desktopId, + execProgram, + fallbackName, + mergeApps, + osFamily, + parseApp, + parseQuery, + parseTerminalsList, + preferTerminal, + terminalFromEnv, +} from "./lib.ts"; + +const cat = (id: string) => CATEGORIES.find((c) => c.id === id)!; + +const FIREFOX = `[Desktop Entry] +Version=1.0 +Name=Firefox +Name[fr]=Firefox +GenericName=Web Browser +Exec=/usr/lib/firefox/firefox %u +Type=Application +MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;application/pdf; +Categories=Network;WebBrowser; + +[Desktop Action new-window] +Name=New Window +Exec=/usr/lib/firefox/firefox --new-window %u +MimeType=should/not-count; +`; + +const app = (id: string, name: string, mimes: string[], extra: Partial = {}): App => ({ + id, + name, + exec: id.replace(".desktop", ""), + mimes, + categories: [], + noDisplay: false, + path: `/usr/share/applications/${id}`, + ...extra, +}); + +Deno.test("parseApp reads the main group and skips hidden entries", () => { + const ff = parseApp("firefox.desktop", "/usr/share/applications/firefox.desktop", FIREFOX)!; + assertEquals(ff.name, "Firefox"); + assertEquals(ff.mimes.length, 6); + assert(!ff.mimes.includes("should/not-count")); + assertEquals(ff.categories, ["Network", "WebBrowser"]); + assertEquals(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nHidden=true\n"), undefined); + assertEquals(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nType=Link\n"), undefined); + assert(parseApp("x.desktop", "/x", "[Desktop Entry]\nName=X\nNoDisplay=true\n")!.noDisplay); +}); + +Deno.test("desktopId and mergeApps follow the XDG rules", () => { + assertEquals(desktopId("kde4/okular.desktop"), "kde4-okular.desktop"); + const user = app("firefox.desktop", "Firefox (mine)", []); + const system = app("firefox.desktop", "Firefox", []); + assertEquals( + mergeApps([[user], [system, app("vlc.desktop", "VLC", [])]]).get("firefox.desktop")?.name, + "Firefox (mine)", + ); +}); + +Deno.test("candidates ranks apps by how many types they cover, hidden apps last", () => { + const apps = [ + app("org.gnome.Loupe.desktop", "Image Viewer", ["image/png", "image/jpeg", "image/webp", "image/gif"]), + app("gimp.desktop", "GNU Image Manipulation Program", ["image/png", "image/jpeg"]), + app("display-im6.q16.desktop", "ImageMagick", ["image/png"], { noDisplay: true }), + app("vlc.desktop", "VLC", ["video/mp4"]), + ]; + assertEquals(candidates(cat("images"), apps).map((a) => a.id), [ + "org.gnome.Loupe.desktop", + "gimp.desktop", + "display-im6.q16.desktop", + ]); + const foot = app("foot.desktop", "Foot", [], { categories: ["System", "TerminalEmulator"] }); + assertEquals(candidates(cat("terminal"), [...apps, foot]).map((a) => a.id), ["foot.desktop"]); +}); + +Deno.test("currentDefault spots mixed defaults", () => { + const browser = currentDefault(cat("browser"), { + "x-scheme-handler/http": "firefox.desktop", + "x-scheme-handler/https": "firefox.desktop", + "text/html": "firefox.desktop", + "application/xhtml+xml": "chromium.desktop", + }, "firefox.desktop"); + assertEquals([browser.id, browser.mixed], ["firefox.desktop", true]); + const pdf = currentDefault(cat("pdf"), { "application/pdf": "org.gnome.Evince.desktop" }); + assertEquals([pdf.id, pdf.mixed], ["org.gnome.Evince.desktop", false]); + const images = currentDefault(cat("images"), { "image/jpeg": "loupe.desktop", "image/gif": "loupe.desktop" }); + assertEquals([images.id, images.mixed], ["loupe.desktop", false]); + const none = currentDefault(cat("video"), {}); + assertEquals([none.id, none.mixed], [undefined, false]); +}); + +Deno.test("parseQuery reads xdg-mime and xdg-settings answers", () => { + assertEquals(parseQuery("org.gnome.Evince.desktop\n"), "org.gnome.Evince.desktop"); + assertEquals(parseQuery("okularApplication_pdf.desktop;okular.desktop;"), "okularApplication_pdf.desktop"); + assertEquals(parseQuery(""), undefined); + assertEquals(parseQuery("xdg-settings: unknown desktop environment"), undefined); +}); + +Deno.test("xdg-terminals.list", () => { + const text = "# preferred terminals\nkitty.desktop\nfoot.desktop:new-window\n\n"; + assertEquals(parseTerminalsList(text), ["kitty.desktop", "foot.desktop"]); + assertEquals( + preferTerminal(text, "org.gnome.Ptyxis.desktop"), + "org.gnome.Ptyxis.desktop\nkitty.desktop\nfoot.desktop:new-window\n", + ); + assertEquals(preferTerminal(text, "kitty.desktop"), "kitty.desktop\nfoot.desktop:new-window\n"); + assertEquals(preferTerminal("", "foot.desktop"), "foot.desktop\n"); +}); + +Deno.test("execProgram, terminalFromEnv and fallbackName", () => { + assertEquals(execProgram("env GDK_BACKEND=x11 /usr/bin/foot --server %U"), "foot"); + const apps = [ + app("org.wezfurlong.wezterm.desktop", "WezTerm", [], { exec: "wezterm start --cwd ." }), + app("Alacritty.desktop", "Alacritty", [], { exec: "alacritty" }), + ]; + assertEquals(terminalFromEnv("/usr/bin/wezterm", apps), "org.wezfurlong.wezterm.desktop"); + assertEquals(terminalFromEnv("alacritty -e", apps), "Alacritty.desktop"); + assertEquals(terminalFromEnv(undefined, apps), undefined); + assertEquals(fallbackName("org.gnome.Nautilus.desktop"), "Nautilus"); + assertEquals(fallbackName("google-chrome.desktop"), "Google Chrome"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); +}); diff --git a/extensions/dev-reference/backslash.toml b/extensions/dev-reference/backslash.toml new file mode 100644 index 0000000..82846af --- /dev/null +++ b/extensions/dev-reference/backslash.toml @@ -0,0 +1,35 @@ +[extension] +id = "dev-reference" +title = "Dev Reference" +description = "HTTP status codes, MIME types, ports, ASCII, HTML entities, key codes, signals, exit codes and a chmod calculator" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# No permission: the tables ship with the extension. + +[[commands]] +name = "dev-reference" +title = "Dev Reference" +description = "Search the tables developers look up: 404, .webp, 5432, SIGTERM,  , KeyA, 137, chmod 755" +icon = "bs-book" +keywords = [ + "http", + "status", + "mime", + "content-type", + "port", + "ascii", + "html entity", + "keycode", + "signal", + "exit code", + "chmod", + "permissions", + "reference", + "référence", + "code de statut", + "droits", + "touche", +] diff --git a/extensions/dev-reference/commands/dev-reference.ts b/extensions/dev-reference/commands/dev-reference.ts new file mode 100644 index 0000000..c4e2ee7 --- /dev/null +++ b/extensions/dev-reference/commands/dev-reference.ts @@ -0,0 +1,59 @@ +import { type Action, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { commonModes, entries, type Entry, modeEntry, parseMode, search, SECTIONS } from "../lib.ts"; + +function item(e: Entry): Item { + const section = SECTIONS[e.table]; + const actions: Action[] = e.copies.map(([title, text], i) => + copy(text, title, i === 1 ? { shortcut: "c" } : i > 1 ? { section: "Copy" } : {}) + ); + if (e.link) actions.push(open(e.link, "Open Documentation", { shortcut: "o" })); + return { + id: e.id, + title: e.title, + subtitle: e.subtitle, + icon: { source: section.icon, tint: e.tint }, + section: section.title, + accessories: e.accessory ? [{ text: e.accessory }] : [], + detail: { + markdown: e.markdown, + metadata: [ + meta.text("Table", section.title, { icon: section.icon }), + meta.separator(), + ...e.facts.map(([label, value]) => meta.text(label, value)), + ], + }, + actions, + }; +} + +export default list({ + placeholder: "Search 404, .png, 5432, SIGTERM,  , KeyA, 137… or type 755 / rwxr-x---", + filter: { + options: [ + { value: "all", title: "All" }, + ...Object.entries(SECTIONS).map(([value, s]) => ({ value, title: s.title })), + ], + }, + empty: { title: "Nothing matches", description: "Try a code, a name or an extension.", icon: Icons.search }, + status: (items) => { + const sections = new Set(items.map((i) => i.section)).size; + return `${items.length} entr${items.length === 1 ? "y" : "ies"}${sections > 1 ? ` in ${sections} tables` : ""}`; + }, + search(query, { filter }) { + const table = filter && filter !== "all" ? filter : undefined; + const mode = parseMode(query); + const modes: Entry[] = []; + if (!table || table === "chmod") { + if (mode !== undefined) modes.push(modeEntry(mode)); + else if (table === "chmod" || !query.trim()) { + modes.push(...(query.trim() ? search(commonModes(), query) : commonModes())); + } + } + const pool = table === "chmod" ? [] : table ? entries().filter((e) => e.table === table) : entries(); + const found = search(pool, query); + // "404" is a status code before it is a mode; "755" or "rwxr-x---" is a mode first. + const exact = found.some((e) => e.keys.includes(query.trim().toLowerCase())); + const modeFirst = mode !== undefined && (!/^\d+$/.test(query.trim()) || !exact); + return (modeFirst || !query.trim() ? [...modes, ...found] : [...found, ...modes]).map(item); + }, +}); diff --git a/extensions/dev-reference/data.ts b/extensions/dev-reference/data.ts new file mode 100644 index 0000000..85a7b4d --- /dev/null +++ b/extensions/dev-reference/data.ts @@ -0,0 +1,574 @@ +// The reference tables. Plain data, shown and searched by lib.ts. + +/** [code, name, description] */ +export const HTTP_STATUS: [number, string, string][] = [ + [100, "Continue", "The client may send the request body."], + [101, "Switching Protocols", "The server switches to the protocol in the Upgrade header (WebSocket)."], + [102, "Processing", "WebDAV: the request is being processed, no response yet."], + [103, "Early Hints", "Preload hints (Link headers) before the final response."], + [200, "OK", "The request succeeded."], + [201, "Created", "A resource was created; Location usually points to it."], + [202, "Accepted", "Accepted for processing, which hasn't finished."], + [203, "Non-Authoritative Information", "The payload was modified by a proxy."], + [204, "No Content", "Success, with no body."], + [205, "Reset Content", "Success; the client should reset its form or view."], + [206, "Partial Content", "The requested byte range (Range header)."], + [207, "Multi-Status", "WebDAV: several status codes in an XML body."], + [208, "Already Reported", "WebDAV: members already listed in a previous part."], + [226, "IM Used", "Delta encoding: the response is the result of instance manipulations."], + [300, "Multiple Choices", "Several representations; the client picks one."], + [301, "Moved Permanently", "The resource has a new permanent URL (Location). Methods may change to GET."], + [302, "Found", "Temporary redirect. Methods may change to GET."], + [303, "See Other", "Fetch the result with GET at the Location URL (after a POST)."], + [304, "Not Modified", "The cached copy is still valid (ETag, If-Modified-Since)."], + [307, "Temporary Redirect", "Temporary redirect keeping the method and body."], + [308, "Permanent Redirect", "Permanent redirect keeping the method and body."], + [400, "Bad Request", "The request is malformed or invalid."], + [401, "Unauthorized", "Authentication is missing or wrong (WWW-Authenticate says how)."], + [402, "Payment Required", "Reserved; some APIs use it for billing limits."], + [403, "Forbidden", "Authenticated, but not allowed."], + [404, "Not Found", "Nothing at this URL."], + [405, "Method Not Allowed", "The method isn't supported here (Allow lists those that are)."], + [406, "Not Acceptable", "No representation matches the Accept headers."], + [407, "Proxy Authentication Required", "Authenticate with the proxy first."], + [408, "Request Timeout", "The server gave up waiting for the request."], + [409, "Conflict", "The request conflicts with the current state (edit conflict, duplicate)."], + [410, "Gone", "The resource was removed for good."], + [411, "Length Required", "Content-Length is required."], + [412, "Precondition Failed", "An If-Match / If-Unmodified-Since condition failed."], + [413, "Content Too Large", "The body is larger than the server accepts."], + [414, "URI Too Long", "The URL is longer than the server accepts."], + [415, "Unsupported Media Type", "The Content-Type of the body isn't supported."], + [416, "Range Not Satisfiable", "The Range is outside the resource."], + [417, "Expectation Failed", "The Expect header can't be met."], + [418, "I'm a teapot", "RFC 2324 joke; some servers use it to refuse bots."], + [421, "Misdirected Request", "The connection can't serve this host (HTTP/2 coalescing)."], + [422, "Unprocessable Content", "Well-formed but semantically invalid (validation errors)."], + [423, "Locked", "WebDAV: the resource is locked."], + [424, "Failed Dependency", "WebDAV: a previous request failed."], + [425, "Too Early", "The server won't risk replaying an early-data request."], + [426, "Upgrade Required", "Switch to the protocol in the Upgrade header."], + [428, "Precondition Required", "The request must be conditional (If-Match)."], + [429, "Too Many Requests", "Rate limited; Retry-After says when to try again."], + [431, "Request Header Fields Too Large", "Headers (often cookies) are too large."], + [451, "Unavailable For Legal Reasons", "Blocked for legal reasons."], + [500, "Internal Server Error", "The server failed unexpectedly."], + [501, "Not Implemented", "The server doesn't support this functionality."], + [502, "Bad Gateway", "A proxy got an invalid response from the upstream server."], + [503, "Service Unavailable", "Overloaded or down for maintenance; Retry-After may say when."], + [504, "Gateway Timeout", "A proxy timed out waiting for the upstream server."], + [505, "HTTP Version Not Supported", "The HTTP version isn't supported."], + [506, "Variant Also Negotiates", "Content negotiation misconfiguration."], + [507, "Insufficient Storage", "WebDAV: no space left to store the representation."], + [508, "Loop Detected", "WebDAV: infinite loop while processing."], + [510, "Not Extended", "Further extensions are required."], + [511, "Network Authentication Required", "Log in to the network first (captive portal)."], +]; + +/** [extension, MIME type, kind] */ +export const MIME_TYPES: [string, string, string][] = [ + ["html", "text/html", "HTML document"], + ["htm", "text/html", "HTML document"], + ["css", "text/css", "Stylesheet"], + ["js", "text/javascript", "JavaScript"], + ["mjs", "text/javascript", "JavaScript module"], + ["ts", "video/mp2t", "MPEG transport stream (TypeScript is usually served as text/plain or application/typescript)"], + ["json", "application/json", "JSON"], + ["jsonld", "application/ld+json", "JSON-LD"], + ["map", "application/json", "Source map"], + ["xml", "application/xml", "XML"], + ["xhtml", "application/xhtml+xml", "XHTML"], + ["txt", "text/plain", "Plain text"], + ["md", "text/markdown", "Markdown"], + ["csv", "text/csv", "Comma-separated values"], + ["tsv", "text/tab-separated-values", "Tab-separated values"], + ["ics", "text/calendar", "iCalendar"], + ["vcf", "text/vcard", "vCard"], + ["yaml", "application/yaml", "YAML"], + ["yml", "application/yaml", "YAML"], + ["toml", "application/toml", "TOML"], + ["wasm", "application/wasm", "WebAssembly"], + ["webmanifest", "application/manifest+json", "Web app manifest"], + ["rss", "application/rss+xml", "RSS feed"], + ["atom", "application/atom+xml", "Atom feed"], + ["pdf", "application/pdf", "PDF document"], + ["rtf", "application/rtf", "Rich Text Format"], + ["doc", "application/msword", "Word document (legacy)"], + ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Word document"], + ["xls", "application/vnd.ms-excel", "Excel spreadsheet (legacy)"], + ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Excel spreadsheet"], + ["ppt", "application/vnd.ms-powerpoint", "PowerPoint (legacy)"], + ["pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "PowerPoint presentation"], + ["odt", "application/vnd.oasis.opendocument.text", "OpenDocument text"], + ["ods", "application/vnd.oasis.opendocument.spreadsheet", "OpenDocument spreadsheet"], + ["odp", "application/vnd.oasis.opendocument.presentation", "OpenDocument presentation"], + ["epub", "application/epub+zip", "EPUB e-book"], + ["png", "image/png", "PNG image"], + ["jpg", "image/jpeg", "JPEG image"], + ["jpeg", "image/jpeg", "JPEG image"], + ["gif", "image/gif", "GIF image"], + ["webp", "image/webp", "WebP image"], + ["avif", "image/avif", "AVIF image"], + ["heic", "image/heic", "HEIC image"], + ["jxl", "image/jxl", "JPEG XL image"], + ["svg", "image/svg+xml", "SVG image"], + ["ico", "image/vnd.microsoft.icon", "Icon"], + ["bmp", "image/bmp", "Bitmap image"], + ["tif", "image/tiff", "TIFF image"], + ["tiff", "image/tiff", "TIFF image"], + ["mp3", "audio/mpeg", "MP3 audio"], + ["ogg", "audio/ogg", "Ogg audio"], + ["oga", "audio/ogg", "Ogg audio"], + ["opus", "audio/opus", "Opus audio"], + ["wav", "audio/wav", "WAV audio"], + ["flac", "audio/flac", "FLAC audio"], + ["aac", "audio/aac", "AAC audio"], + ["m4a", "audio/mp4", "MPEG-4 audio"], + ["weba", "audio/webm", "WebM audio"], + ["mid", "audio/midi", "MIDI"], + ["mp4", "video/mp4", "MPEG-4 video"], + ["m4v", "video/mp4", "MPEG-4 video"], + ["webm", "video/webm", "WebM video"], + ["ogv", "video/ogg", "Ogg video"], + ["mkv", "video/x-matroska", "Matroska video"], + ["mov", "video/quicktime", "QuickTime video"], + ["avi", "video/x-msvideo", "AVI video"], + ["mpeg", "video/mpeg", "MPEG video"], + ["m3u8", "application/vnd.apple.mpegurl", "HLS playlist"], + ["woff", "font/woff", "WOFF font"], + ["woff2", "font/woff2", "WOFF2 font"], + ["ttf", "font/ttf", "TrueType font"], + ["otf", "font/otf", "OpenType font"], + ["zip", "application/zip", "ZIP archive"], + ["gz", "application/gzip", "Gzip archive"], + ["tgz", "application/gzip", "Gzipped tarball"], + ["tar", "application/x-tar", "Tar archive"], + ["bz2", "application/x-bzip2", "Bzip2 archive"], + ["xz", "application/x-xz", "XZ archive"], + ["zst", "application/zstd", "Zstandard archive"], + ["7z", "application/x-7z-compressed", "7-Zip archive"], + ["rar", "application/vnd.rar", "RAR archive"], + ["deb", "application/vnd.debian.binary-package", "Debian package"], + ["rpm", "application/x-rpm", "RPM package"], + ["flatpak", "application/vnd.flatpak", "Flatpak bundle"], + ["appimage", "application/vnd.appimage", "AppImage"], + ["iso", "application/x-iso9660-image", "Disk image"], + ["jar", "application/java-archive", "Java archive"], + ["sh", "application/x-sh", "Shell script"], + ["py", "text/x-python", "Python source"], + ["sql", "application/sql", "SQL"], + ["bin", "application/octet-stream", "Binary data (the default for unknown files)"], + ["form", "application/x-www-form-urlencoded", "HTML form body (no extension)"], + ["multipart", "multipart/form-data", "Form body with files (no extension)"], + ["ndjson", "application/x-ndjson", "Newline-delimited JSON"], + ["sse", "text/event-stream", "Server-sent events (no extension)"], + ["pem", "application/x-pem-file", "PEM certificate or key"], + ["crt", "application/x-x509-ca-cert", "X.509 certificate"], + ["torrent", "application/x-bittorrent", "BitTorrent"], +]; + +/** [port, protocol, service, description] */ +export const PORTS: [number, string, string, string][] = [ + [20, "TCP", "FTP data", "File Transfer Protocol data channel"], + [21, "TCP", "FTP", "File Transfer Protocol control"], + [22, "TCP", "SSH", "Secure Shell, SFTP, scp, git over SSH"], + [23, "TCP", "Telnet", "Unencrypted remote login"], + [25, "TCP", "SMTP", "Mail transfer between servers"], + [53, "TCP/UDP", "DNS", "Domain Name System"], + [67, "UDP", "DHCP server", "Dynamic Host Configuration Protocol (server)"], + [68, "UDP", "DHCP client", "Dynamic Host Configuration Protocol (client)"], + [69, "UDP", "TFTP", "Trivial File Transfer Protocol (PXE boot)"], + [80, "TCP", "HTTP", "The web, unencrypted"], + [88, "TCP/UDP", "Kerberos", "Authentication"], + [110, "TCP", "POP3", "Mail retrieval"], + [111, "TCP/UDP", "rpcbind", "ONC RPC portmapper (NFS v3)"], + [119, "TCP", "NNTP", "Usenet news"], + [123, "UDP", "NTP", "Network Time Protocol"], + [137, "UDP", "NetBIOS", "NetBIOS name service"], + [139, "TCP", "NetBIOS", "NetBIOS session (old SMB)"], + [143, "TCP", "IMAP", "Mail access"], + [161, "UDP", "SNMP", "Network management"], + [162, "UDP", "SNMP traps", "Network management notifications"], + [179, "TCP", "BGP", "Border Gateway Protocol"], + [389, "TCP", "LDAP", "Directory access"], + [443, "TCP/UDP", "HTTPS", "The web over TLS; UDP for HTTP/3 (QUIC)"], + [445, "TCP", "SMB", "Windows file sharing, Samba"], + [465, "TCP", "SMTPS", "Mail submission over TLS"], + [500, "UDP", "IKE", "IPsec key exchange"], + [514, "UDP", "Syslog", "Remote logging"], + [515, "TCP", "LPD", "Line printer daemon"], + [587, "TCP", "Submission", "Mail submission (STARTTLS)"], + [631, "TCP", "IPP", "Internet Printing Protocol, CUPS"], + [636, "TCP", "LDAPS", "LDAP over TLS"], + [853, "TCP", "DNS over TLS", "Encrypted DNS"], + [873, "TCP", "rsync", "rsync daemon"], + [990, "TCP", "FTPS", "FTP over TLS"], + [993, "TCP", "IMAPS", "IMAP over TLS"], + [995, "TCP", "POP3S", "POP3 over TLS"], + [1080, "TCP", "SOCKS", "SOCKS proxy"], + [1194, "UDP", "OpenVPN", "OpenVPN"], + [1433, "TCP", "SQL Server", "Microsoft SQL Server"], + [1521, "TCP", "Oracle", "Oracle database listener"], + [1883, "TCP", "MQTT", "Message queue for IoT"], + [2049, "TCP/UDP", "NFS", "Network File System"], + [2375, "TCP", "Docker", "Docker API, unencrypted"], + [2376, "TCP", "Docker TLS", "Docker API over TLS"], + [2379, "TCP", "etcd", "etcd client API"], + [3000, "TCP", "Dev server", "Node, Rails, Grafana default"], + [3306, "TCP", "MySQL", "MySQL, MariaDB"], + [3389, "TCP", "RDP", "Remote Desktop Protocol"], + [3478, "TCP/UDP", "STUN/TURN", "NAT traversal for WebRTC"], + [4369, "TCP", "EPMD", "Erlang port mapper (RabbitMQ, Elixir)"], + [5000, "TCP", "Dev server", "Flask, Docker registry default"], + [5173, "TCP", "Vite", "Vite dev server"], + [5222, "TCP", "XMPP", "Jabber client connections"], + [5353, "UDP", "mDNS", "Multicast DNS (Avahi, Bonjour)"], + [5432, "TCP", "PostgreSQL", "PostgreSQL"], + [5672, "TCP", "AMQP", "RabbitMQ"], + [5900, "TCP", "VNC", "Remote framebuffer (display :0)"], + [6379, "TCP", "Redis", "Redis, Valkey"], + [6443, "TCP", "Kubernetes API", "kube-apiserver"], + [6667, "TCP", "IRC", "Internet Relay Chat"], + [6697, "TCP", "IRC TLS", "IRC over TLS"], + [8000, "TCP", "HTTP alt", "Python http.server, Django default"], + [8080, "TCP", "HTTP alt", "Proxies, Tomcat, dev servers"], + [8086, "TCP", "InfluxDB", "InfluxDB HTTP API"], + [8443, "TCP", "HTTPS alt", "Alternative HTTPS"], + [8883, "TCP", "MQTT TLS", "MQTT over TLS"], + [9000, "TCP", "Dev server", "PHP-FPM, SonarQube, MinIO"], + [9090, "TCP", "Prometheus", "Prometheus, Cockpit"], + [9092, "TCP", "Kafka", "Apache Kafka"], + [9100, "TCP", "Node exporter", "Prometheus node exporter, raw printing"], + [9200, "TCP", "Elasticsearch", "Elasticsearch, OpenSearch HTTP"], + [9418, "TCP", "Git", "git:// protocol"], + [11211, "TCP", "Memcached", "Memcached"], + [11434, "TCP", "Ollama", "Ollama API"], + [15672, "TCP", "RabbitMQ UI", "RabbitMQ management"], + [25565, "TCP", "Minecraft", "Minecraft server"], + [27017, "TCP", "MongoDB", "MongoDB"], + [51820, "UDP", "WireGuard", "WireGuard default"], +]; + +/** Names of the control characters 0–31 and 127: [abbreviation, name, escape]. */ +export const ASCII_CONTROL: Record = { + 0: ["NUL", "Null", "\\0"], + 1: ["SOH", "Start of heading"], + 2: ["STX", "Start of text"], + 3: ["ETX", "End of text (Ctrl+C)"], + 4: ["EOT", "End of transmission (Ctrl+D)"], + 5: ["ENQ", "Enquiry"], + 6: ["ACK", "Acknowledge"], + 7: ["BEL", "Bell", "\\a"], + 8: ["BS", "Backspace", "\\b"], + 9: ["HT", "Horizontal tab", "\\t"], + 10: ["LF", "Line feed", "\\n"], + 11: ["VT", "Vertical tab", "\\v"], + 12: ["FF", "Form feed", "\\f"], + 13: ["CR", "Carriage return", "\\r"], + 14: ["SO", "Shift out"], + 15: ["SI", "Shift in"], + 16: ["DLE", "Data link escape"], + 17: ["DC1", "Device control 1 (XON, Ctrl+Q)"], + 18: ["DC2", "Device control 2"], + 19: ["DC3", "Device control 3 (XOFF, Ctrl+S)"], + 20: ["DC4", "Device control 4"], + 21: ["NAK", "Negative acknowledge"], + 22: ["SYN", "Synchronous idle"], + 23: ["ETB", "End of transmission block"], + 24: ["CAN", "Cancel"], + 25: ["EM", "End of medium"], + 26: ["SUB", "Substitute (Ctrl+Z)"], + 27: ["ESC", "Escape", "\\e"], + 28: ["FS", "File separator"], + 29: ["GS", "Group separator"], + 30: ["RS", "Record separator"], + 31: ["US", "Unit separator"], + 32: ["SP", "Space"], + 127: ["DEL", "Delete"], +}; + +/** [entity name, character, description] */ +export const HTML_ENTITIES: [string, string, string][] = [ + ["amp", "&", "Ampersand"], + ["lt", "<", "Less-than sign"], + ["gt", ">", "Greater-than sign"], + ["quot", '"', "Quotation mark"], + ["apos", "'", "Apostrophe"], + ["nbsp", " ", "No-break space"], + ["ensp", " ", "En space"], + ["emsp", " ", "Em space"], + ["thinsp", " ", "Thin space"], + ["zwj", "‍", "Zero-width joiner"], + ["zwnj", "‌", "Zero-width non-joiner"], + ["shy", "­", "Soft hyphen"], + ["copy", "©", "Copyright"], + ["reg", "®", "Registered"], + ["trade", "™", "Trade mark"], + ["sect", "§", "Section"], + ["para", "¶", "Pilcrow (paragraph)"], + ["deg", "°", "Degree"], + ["plusmn", "±", "Plus-minus"], + ["times", "×", "Multiplication"], + ["divide", "÷", "Division"], + ["minus", "−", "Minus"], + ["ne", "≠", "Not equal"], + ["le", "≤", "Less-than or equal"], + ["ge", "≥", "Greater-than or equal"], + ["asymp", "≈", "Almost equal"], + ["equiv", "≡", "Identical to"], + ["infin", "∞", "Infinity"], + ["sum", "∑", "Summation"], + ["prod", "∏", "Product"], + ["radic", "√", "Square root"], + ["part", "∂", "Partial differential"], + ["int", "∫", "Integral"], + ["forall", "∀", "For all"], + ["exist", "∃", "There exists"], + ["isin", "∈", "Element of"], + ["empty", "∅", "Empty set"], + ["and", "∧", "Logical and"], + ["or", "∨", "Logical or"], + ["not", "¬", "Not sign"], + ["micro", "µ", "Micro"], + ["permil", "‰", "Per mille"], + ["frac12", "½", "One half"], + ["frac14", "¼", "One quarter"], + ["frac34", "¾", "Three quarters"], + ["sup2", "²", "Superscript two"], + ["sup3", "³", "Superscript three"], + ["middot", "·", "Middle dot"], + ["bull", "•", "Bullet"], + ["hellip", "…", "Ellipsis"], + ["ndash", "–", "En dash"], + ["mdash", "—", "Em dash"], + ["lsquo", "‘", "Left single quote"], + ["rsquo", "’", "Right single quote"], + ["ldquo", "“", "Left double quote"], + ["rdquo", "”", "Right double quote"], + ["sbquo", "‚", "Low single quote"], + ["bdquo", "„", "Low double quote"], + ["laquo", "«", "Left guillemet"], + ["raquo", "»", "Right guillemet"], + ["lsaquo", "‹", "Single left angle quote"], + ["rsaquo", "›", "Single right angle quote"], + ["prime", "′", "Prime (minutes, feet)"], + ["Prime", "″", "Double prime (seconds, inches)"], + ["dagger", "†", "Dagger"], + ["Dagger", "‡", "Double dagger"], + ["iexcl", "¡", "Inverted exclamation mark"], + ["iquest", "¿", "Inverted question mark"], + ["euro", "€", "Euro"], + ["pound", "£", "Pound"], + ["yen", "¥", "Yen"], + ["cent", "¢", "Cent"], + ["curren", "¤", "Currency sign"], + ["larr", "←", "Left arrow"], + ["rarr", "→", "Right arrow"], + ["uarr", "↑", "Up arrow"], + ["darr", "↓", "Down arrow"], + ["harr", "↔", "Left-right arrow"], + ["crarr", "↵", "Carriage return arrow"], + ["lArr", "⇐", "Double left arrow"], + ["rArr", "⇒", "Double right arrow (implies)"], + ["hArr", "⇔", "Double left-right arrow (iff)"], + ["check", "✓", "Check mark"], + ["cross", "✗", "Ballot X"], + ["star", "☆", "White star"], + ["starf", "★", "Black star"], + ["hearts", "♥", "Heart suit"], + ["spades", "♠", "Spade suit"], + ["clubs", "♣", "Club suit"], + ["diams", "♦", "Diamond suit"], + ["loz", "◊", "Lozenge"], + ["alpha", "α", "Greek small alpha"], + ["beta", "β", "Greek small beta"], + ["gamma", "γ", "Greek small gamma"], + ["delta", "δ", "Greek small delta"], + ["Delta", "Δ", "Greek capital delta"], + ["epsilon", "ε", "Greek small epsilon"], + ["lambda", "λ", "Greek small lambda"], + ["mu", "μ", "Greek small mu"], + ["pi", "π", "Greek small pi"], + ["sigma", "σ", "Greek small sigma"], + ["Sigma", "Σ", "Greek capital sigma"], + ["tau", "τ", "Greek small tau"], + ["phi", "φ", "Greek small phi"], + ["omega", "ω", "Greek small omega"], + ["Omega", "Ω", "Greek capital omega (ohm)"], + ["eacute", "é", "e with acute"], + ["egrave", "è", "e with grave"], + ["ecirc", "ê", "e with circumflex"], + ["agrave", "à", "a with grave"], + ["ccedil", "ç", "c with cedilla"], + ["ouml", "ö", "o with diaeresis"], + ["uuml", "ü", "u with diaeresis"], + ["ntilde", "ñ", "n with tilde"], + ["szlig", "ß", "Sharp s"], + ["aelig", "æ", "ae ligature"], + ["oelig", "œ", "oe ligature"], + ["oslash", "ø", "o with stroke"], + ["aring", "å", "a with ring"], +]; + +/** [label, KeyboardEvent.key, KeyboardEvent.code, legacy keyCode, Linux evdev name, evdev code] */ +export const KEYS: [string, string, string, number | null, string, number][] = [ + ["Escape", "Escape", "Escape", 27, "KEY_ESC", 1], + ["Enter", "Enter", "Enter", 13, "KEY_ENTER", 28], + ["Tab", "Tab", "Tab", 9, "KEY_TAB", 15], + ["Space", " ", "Space", 32, "KEY_SPACE", 57], + ["Backspace", "Backspace", "Backspace", 8, "KEY_BACKSPACE", 14], + ["Delete", "Delete", "Delete", 46, "KEY_DELETE", 111], + ["Insert", "Insert", "Insert", 45, "KEY_INSERT", 110], + ["Home", "Home", "Home", 36, "KEY_HOME", 102], + ["End", "End", "End", 35, "KEY_END", 107], + ["Page Up", "PageUp", "PageUp", 33, "KEY_PAGEUP", 104], + ["Page Down", "PageDown", "PageDown", 34, "KEY_PAGEDOWN", 109], + ["Arrow Up", "ArrowUp", "ArrowUp", 38, "KEY_UP", 103], + ["Arrow Down", "ArrowDown", "ArrowDown", 40, "KEY_DOWN", 108], + ["Arrow Left", "ArrowLeft", "ArrowLeft", 37, "KEY_LEFT", 105], + ["Arrow Right", "ArrowRight", "ArrowRight", 39, "KEY_RIGHT", 106], + ["Left Shift", "Shift", "ShiftLeft", 16, "KEY_LEFTSHIFT", 42], + ["Right Shift", "Shift", "ShiftRight", 16, "KEY_RIGHTSHIFT", 54], + ["Left Ctrl", "Control", "ControlLeft", 17, "KEY_LEFTCTRL", 29], + ["Right Ctrl", "Control", "ControlRight", 17, "KEY_RIGHTCTRL", 97], + ["Left Alt", "Alt", "AltLeft", 18, "KEY_LEFTALT", 56], + ["Right Alt (AltGr)", "AltGraph", "AltRight", 18, "KEY_RIGHTALT", 100], + ["Left Super", "Meta", "MetaLeft", 91, "KEY_LEFTMETA", 125], + ["Right Super", "Meta", "MetaRight", 92, "KEY_RIGHTMETA", 126], + ["Menu", "ContextMenu", "ContextMenu", 93, "KEY_COMPOSE", 127], + ["Caps Lock", "CapsLock", "CapsLock", 20, "KEY_CAPSLOCK", 58], + ["Num Lock", "NumLock", "NumLock", 144, "KEY_NUMLOCK", 69], + ["Scroll Lock", "ScrollLock", "ScrollLock", 145, "KEY_SCROLLLOCK", 70], + ["Print Screen", "PrintScreen", "PrintScreen", 44, "KEY_SYSRQ", 99], + ["Pause", "Pause", "Pause", 19, "KEY_PAUSE", 119], + ["`", "`", "Backquote", 192, "KEY_GRAVE", 41], + ["-", "-", "Minus", 189, "KEY_MINUS", 12], + ["=", "=", "Equal", 187, "KEY_EQUAL", 13], + ["[", "[", "BracketLeft", 219, "KEY_LEFTBRACE", 26], + ["]", "]", "BracketRight", 221, "KEY_RIGHTBRACE", 27], + ["\\", "\\", "Backslash", 220, "KEY_BACKSLASH", 43], + [";", ";", "Semicolon", 186, "KEY_SEMICOLON", 39], + ["'", "'", "Quote", 222, "KEY_APOSTROPHE", 40], + [",", ",", "Comma", 188, "KEY_COMMA", 51], + [".", ".", "Period", 190, "KEY_DOT", 52], + ["/", "/", "Slash", 191, "KEY_SLASH", 53], + ["Numpad Enter", "Enter", "NumpadEnter", 13, "KEY_KPENTER", 96], + ["Numpad +", "+", "NumpadAdd", 107, "KEY_KPPLUS", 78], + ["Numpad −", "-", "NumpadSubtract", 109, "KEY_KPMINUS", 74], + ["Numpad *", "*", "NumpadMultiply", 106, "KEY_KPASTERISK", 55], + ["Numpad /", "/", "NumpadDivide", 111, "KEY_KPSLASH", 98], + ["Numpad .", ".", "NumpadDecimal", 110, "KEY_KPDOT", 83], + ["Mute", "AudioVolumeMute", "AudioVolumeMute", 173, "KEY_MUTE", 113], + ["Volume Down", "AudioVolumeDown", "AudioVolumeDown", 174, "KEY_VOLUMEDOWN", 114], + ["Volume Up", "AudioVolumeUp", "AudioVolumeUp", 175, "KEY_VOLUMEUP", 115], + ["Play/Pause", "MediaPlayPause", "MediaPlayPause", 179, "KEY_PLAYPAUSE", 164], + ["Next Track", "MediaTrackNext", "MediaTrackNext", 176, "KEY_NEXTSONG", 163], + ["Previous Track", "MediaTrackPrevious", "MediaTrackPrevious", 177, "KEY_PREVIOUSSONG", 165], + ["Brightness Up", "BrightnessUp", "BrightnessUp", null, "KEY_BRIGHTNESSUP", 225], + ["Brightness Down", "BrightnessDown", "BrightnessDown", null, "KEY_BRIGHTNESSDOWN", 224], +]; + +// Letters, digits, function keys and the number pad follow patterns. +// deno-fmt-ignore +const LETTER_EVDEV: Record = { + Q: 16, W: 17, E: 18, R: 19, T: 20, Y: 21, U: 22, I: 23, O: 24, P: 25, + A: 30, S: 31, D: 32, F: 33, G: 34, H: 35, J: 36, K: 37, L: 38, + Z: 44, X: 45, C: 46, V: 47, B: 48, N: 49, M: 50, +}; +for (const [letter, evdev] of Object.entries(LETTER_EVDEV).sort()) { + KEYS.push([letter, letter.toLowerCase(), `Key${letter}`, letter.charCodeAt(0), `KEY_${letter}`, evdev]); +} +for (let d = 0; d <= 9; d++) KEYS.push([String(d), String(d), `Digit${d}`, 48 + d, `KEY_${d}`, d === 0 ? 11 : d + 1]); +for (let f = 1; f <= 12; f++) KEYS.push([`F${f}`, `F${f}`, `F${f}`, 111 + f, `KEY_F${f}`, f <= 10 ? 58 + f : 76 + f]); +const KP_EVDEV = [82, 79, 80, 81, 75, 76, 77, 71, 72, 73]; +for (let d = 0; d <= 9; d++) KEYS.push([`Numpad ${d}`, String(d), `Numpad${d}`, 96 + d, `KEY_KP${d}`, KP_EVDEV[d]]); + +/** [number, name, default action, description] on Linux x86 and ARM. */ +export const SIGNALS: [number, string, string, string][] = [ + [1, "SIGHUP", "Terminate", "Terminal closed; daemons often reload their configuration"], + [2, "SIGINT", "Terminate", "Interrupt from the keyboard (Ctrl+C)"], + [3, "SIGQUIT", "Core dump", "Quit from the keyboard (Ctrl+\\)"], + [4, "SIGILL", "Core dump", "Illegal instruction"], + [5, "SIGTRAP", "Core dump", "Breakpoint or trace trap (debuggers)"], + [6, "SIGABRT", "Core dump", "abort(), failed assertion"], + [7, "SIGBUS", "Core dump", "Bus error: bad memory access"], + [8, "SIGFPE", "Core dump", "Arithmetic error, like an integer division by zero"], + [9, "SIGKILL", "Terminate", "Kill at once; can't be caught or ignored"], + [10, "SIGUSR1", "Terminate", "User-defined signal 1"], + [11, "SIGSEGV", "Core dump", "Segmentation fault: invalid memory reference"], + [12, "SIGUSR2", "Terminate", "User-defined signal 2"], + [13, "SIGPIPE", "Terminate", "Write to a pipe with no reader"], + [14, "SIGALRM", "Terminate", "Timer from alarm()"], + [15, "SIGTERM", "Terminate", "Polite termination request (kill's default)"], + [16, "SIGSTKFLT", "Terminate", "Coprocessor stack fault (unused)"], + [17, "SIGCHLD", "Ignore", "A child process stopped or exited"], + [18, "SIGCONT", "Continue", "Continue a stopped process (fg, bg)"], + [19, "SIGSTOP", "Stop", "Stop the process; can't be caught or ignored"], + [20, "SIGTSTP", "Stop", "Stop from the keyboard (Ctrl+Z)"], + [21, "SIGTTIN", "Stop", "Background process read from the terminal"], + [22, "SIGTTOU", "Stop", "Background process wrote to the terminal"], + [23, "SIGURG", "Ignore", "Urgent data on a socket"], + [24, "SIGXCPU", "Core dump", "CPU time limit exceeded"], + [25, "SIGXFSZ", "Core dump", "File size limit exceeded"], + [26, "SIGVTALRM", "Terminate", "Virtual timer expired"], + [27, "SIGPROF", "Terminate", "Profiling timer expired"], + [28, "SIGWINCH", "Ignore", "Terminal window resized"], + [29, "SIGIO", "Terminate", "I/O possible (also SIGPOLL)"], + [30, "SIGPWR", "Terminate", "Power failure"], + [31, "SIGSYS", "Core dump", "Bad system call (seccomp)"], + [34, "SIGRTMIN", "Terminate", "First real-time signal (glibc reserves 32 and 33)"], + [64, "SIGRTMAX", "Terminate", "Last real-time signal"], +]; + +/** [code, name, description] */ +export const EXIT_CODES: [number, string, string][] = [ + [0, "Success", "The command succeeded"], + [1, "General error", "Any failure: the catch-all"], + [2, "Usage error", "Misuse of a shell builtin or wrong arguments (grep: an error occurred)"], + [64, "EX_USAGE", "sysexits.h: the command was used incorrectly"], + [65, "EX_DATAERR", "sysexits.h: the input data was incorrect"], + [66, "EX_NOINPUT", "sysexits.h: an input file didn't exist or wasn't readable"], + [67, "EX_NOUSER", "sysexits.h: the user doesn't exist"], + [68, "EX_NOHOST", "sysexits.h: the host doesn't exist"], + [69, "EX_UNAVAILABLE", "sysexits.h: a service is unavailable"], + [70, "EX_SOFTWARE", "sysexits.h: internal software error"], + [71, "EX_OSERR", "sysexits.h: system error (can't fork)"], + [72, "EX_OSFILE", "sysexits.h: a system file is missing"], + [73, "EX_CANTCREAT", "sysexits.h: can't create an output file"], + [74, "EX_IOERR", "sysexits.h: input/output error"], + [75, "EX_TEMPFAIL", "sysexits.h: temporary failure, try again"], + [76, "EX_PROTOCOL", "sysexits.h: remote protocol error"], + [77, "EX_NOPERM", "sysexits.h: permission denied"], + [78, "EX_CONFIG", "sysexits.h: configuration error"], + [124, "Timed out", "timeout(1): the command ran out of time"], + [125, "Command failed to run", "timeout, env, docker run: the wrapper itself failed"], + [126, "Not executable", "The file was found but can't be executed (permissions, wrong format)"], + [127, "Command not found", "Not in PATH, or a typo"], + [128, "Invalid exit argument", "exit was given something other than 0–255"], + [129, "SIGHUP", "128 + 1: the terminal hung up"], + [130, "SIGINT", "128 + 2: interrupted with Ctrl+C"], + [131, "SIGQUIT", "128 + 3: quit with Ctrl+\\"], + [134, "SIGABRT", "128 + 6: aborted (assertion failed)"], + [137, "SIGKILL", "128 + 9: killed, often by the OOM killer or docker stop"], + [139, "SIGSEGV", "128 + 11: segmentation fault"], + [141, "SIGPIPE", "128 + 13: broken pipe (output closed, like | head)"], + [143, "SIGTERM", "128 + 15: terminated (systemctl stop, kill)"], + [255, "Out of range", "exit -1, or an SSH connection error"], +]; + +/** Modes worth knowing: [octal, what it's for]. */ +export const COMMON_MODES: [string, string][] = [ + ["755", "Directories, scripts and programs"], + ["644", "Ordinary files"], + ["600", "Private files: SSH keys, secrets"], + ["700", "Private directories: ~/.ssh, ~/.gnupg"], + ["640", "Config readable by a group"], + ["775", "Shared directories in a group"], + ["664", "Shared files in a group"], + ["444", "Read-only for everyone"], + ["400", "Read-only for the owner (AWS .pem keys)"], + ["777", "Everyone can do everything: avoid"], + ["4755", "setuid program (passwd, sudo)"], + ["2775", "setgid directory: new files keep the group"], + ["1777", "Sticky world-writable directory (/tmp)"], +]; diff --git a/extensions/dev-reference/lib.ts b/extensions/dev-reference/lib.ts new file mode 100644 index 0000000..522b182 --- /dev/null +++ b/extensions/dev-reference/lib.ts @@ -0,0 +1,421 @@ +// Reference entries, search ranking and the chmod calculator. Pure, tested in lib_test.ts. + +import { + ASCII_CONTROL, + COMMON_MODES, + EXIT_CODES, + HTML_ENTITIES, + HTTP_STATUS, + KEYS, + MIME_TYPES, + PORTS, + SIGNALS, +} from "./data.ts"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface Entry { + id: string; + /** The table it belongs to: a key of {@link SECTIONS}. */ + table: string; + title: string; + subtitle: string; + /** Exact matches rank first ("404", "png", "sigterm"). */ + keys: string[]; + keywords: string[]; + tint: Tint; + /** Short text on the right. */ + accessory?: string; + /** [title, text]: the first is Enter. */ + copies: [string, string][]; + /** [label, value] lines of the side panel. */ + facts: [string, string][]; + markdown?: string; + /** Documentation page. */ + link?: string; +} + +export const SECTIONS: Record = { + chmod: { title: "chmod", icon: "bs-lock" }, + http: { title: "HTTP Status Codes", icon: "bs-globe" }, + mime: { title: "MIME Types", icon: "bs-file" }, + ports: { title: "Ports", icon: "bs-plug" }, + ascii: { title: "ASCII", icon: "bs-text" }, + entities: { title: "HTML Entities", icon: "bs-code" }, + keys: { title: "Keyboard Keys", icon: "bs-keyboard" }, + signals: { title: "Unix Signals", icon: "bs-bolt" }, + exit: { title: "Exit Codes", icon: "bs-terminal" }, +}; + +const hex = (n: number, width = 2) => n.toString(16).toUpperCase().padStart(width, "0"); + +export function httpClass(code: number): { name: string; tint: Tint } { + if (code < 200) return { name: "Informational", tint: "blue" }; + if (code < 300) return { name: "Success", tint: "green" }; + if (code < 400) return { name: "Redirection", tint: "purple" }; + if (code < 500) return { name: "Client error", tint: "orange" }; + return { name: "Server error", tint: "red" }; +} + +function httpEntries(): Entry[] { + return HTTP_STATUS.map(([code, name, description]) => { + const cls = httpClass(code); + return { + id: `http:${code}`, + table: "http", + title: `${code} ${name}`, + subtitle: description, + keys: [String(code), name.toLowerCase()], + keywords: [cls.name, "status", "http"], + tint: cls.tint, + accessory: cls.name, + copies: [["Copy Code", String(code)], ["Copy Code and Name", `${code} ${name}`]], + facts: [["Code", String(code)], ["Name", name], ["Class", `${String(code)[0]}xx · ${cls.name}`]], + markdown: `## ${code} ${name}\n\n${description}`, + link: `https://developer.mozilla.org/docs/Web/HTTP/Reference/Status/${code}`, + }; + }); +} + +function mimeEntries(): Entry[] { + return MIME_TYPES.map(([ext, type, kind]) => ({ + id: `mime:${ext}`, + table: "mime", + title: type, + subtitle: `.${ext} · ${kind}`, + keys: [ext, `.${ext}`, type], + keywords: [kind, type.split("/")[0]], + tint: ({ image: "pink", audio: "purple", video: "red", font: "teal", text: "blue" } as Record)[ + type.split("/")[0] + ] ?? "gray", + accessory: `.${ext}`, + copies: [["Copy MIME Type", type], ["Copy Content-Type Header", `Content-Type: ${type}`]], + facts: [["Extension", `.${ext}`], ["MIME type", type], ["Kind", kind]], + })); +} + +function portEntries(): Entry[] { + return PORTS.map(([port, protocol, service, description]) => ({ + id: `port:${port}`, + table: "ports", + title: `${port} · ${service}`, + subtitle: description, + keys: [String(port), service.toLowerCase()], + keywords: [protocol, port < 1024 ? "well-known privileged" : "registered"], + tint: port < 1024 ? "blue" : "teal", + accessory: protocol, + copies: [["Copy Port", String(port)], ["Copy ss Command", `ss -tulpn 'sport = :${port}'`]], + facts: [ + ["Port", String(port)], + ["Protocol", protocol], + ["Service", service], + ["Range", port < 1024 ? "Well-known (needs root to listen)" : "Registered"], + ], + })); +} + +function asciiEntries(): Entry[] { + const out: Entry[] = []; + for (let n = 0; n < 128; n++) { + const control = ASCII_CONTROL[n]; + const char = String.fromCharCode(n); + const shown = control && n !== 32 ? control[0] : n === 32 ? "Space" : char; + out.push({ + id: `ascii:${n}`, + table: "ascii", + title: control ? `${shown} · ${control[1]}` : char, + subtitle: `${n} · 0x${hex(n)} · 0o${n.toString(8).padStart(3, "0")}`, + keys: [String(n), `0x${hex(n).toLowerCase()}`, ...(control ? [control[0].toLowerCase()] : []), char], + keywords: control + ? [control[1], "control"] + : [/[a-z]/i.test(char) ? "letter" : /\d/.test(char) ? "digit" : "symbol"], + tint: control ? "gray" : "blue", + accessory: `0x${hex(n)}`, + copies: [ + ...(control && n !== 32 ? [] : [["Copy Character", char] as [string, string]]), + ["Copy Decimal", String(n)], + ["Copy Hex", `0x${hex(n)}`], + ...(control?.[2] ? [["Copy Escape", control[2]] as [string, string]] : []), + ], + facts: [ + ["Decimal", String(n)], + ["Hex", `0x${hex(n)}`], + ["Octal", `0o${n.toString(8)}`], + ["Binary", n.toString(2).padStart(8, "0")], + ["HTML", `&#${n};`], + ...(control?.[2] ? [["Escape", control[2]] as [string, string]] : []), + ...(n > 0 && n < 27 ? [["Keyboard", `Ctrl+${String.fromCharCode(64 + n)}`] as [string, string]] : []), + ], + }); + } + return out; +} + +function entityEntries(): Entry[] { + return HTML_ENTITIES.map(([name, char, description]) => { + const cp = char.codePointAt(0)!; + const visible = /\s|[​-‍­]/.test(char) ? `(${description.toLowerCase()})` : char; + return { + id: `entity:${name}`, + table: "entities", + title: `${visible} &${name};`, + subtitle: description, + keys: [name.toLowerCase(), `&${name.toLowerCase()};`, char], + keywords: [`U+${hex(cp, 4)}`], + tint: "purple", + accessory: `U+${hex(cp, 4)}`, + copies: [["Copy Entity", `&${name};`], ["Copy Character", char], ["Copy Numeric Entity", `&#${cp};`]], + facts: [ + ["Character", visible], + ["Named", `&${name};`], + ["Decimal", `&#${cp};`], + ["Hex", `&#x${hex(cp)};`], + ["Code point", `U+${hex(cp, 4)}`], + ["CSS", `\\${hex(cp, 4).toLowerCase()}`], + ], + }; + }); +} + +function keyEntries(): Entry[] { + return KEYS.map(([label, key, code, keyCode, evdev, evcode]) => ({ + id: `key:${code}`, + table: "keys", + title: label, + subtitle: `code ${code} · key ${key === " " ? '" "' : key}`, + keys: [ + label.toLowerCase(), + code.toLowerCase(), + evdev.toLowerCase(), + ...(keyCode !== null ? [String(keyCode)] : []), + ], + keywords: [key, String(evcode), "keycode"], + tint: "gray", + accessory: keyCode !== null ? `keyCode ${keyCode}` : undefined, + copies: [ + ["Copy event.code", code], + ["Copy event.key", key], + ...(keyCode !== null ? [["Copy keyCode", String(keyCode)] as [string, string]] : []), + ["Copy evdev Name", evdev], + ], + facts: [ + ["event.key", key === " " ? '" "' : key], + ["event.code", code], + ["keyCode (legacy)", keyCode === null ? "–" : String(keyCode)], + ["Linux evdev", `${evdev} (${evcode})`], + ["X11 keycode", String(evcode + 8)], + ], + })); +} + +function signalEntries(): Entry[] { + return SIGNALS.map(([n, name, action, description]) => ({ + id: `signal:${n}`, + table: "signals", + title: `${n} · ${name}`, + subtitle: description, + keys: [String(n), name.toLowerCase(), name.slice(3).toLowerCase()], + keywords: [action], + tint: action === "Core dump" ? "red" : action === "Stop" ? "orange" : action === "Ignore" ? "gray" : "yellow", + accessory: action, + copies: [ + ["Copy Name", name], + ["Copy kill Command", `kill -${name.slice(3)} `], + ["Copy Number", String(n)], + ], + facts: [ + ["Number", String(n)], + ["Name", name], + ["Default action", action], + ["Exit status", `${128 + n} when it kills a shell command`], + ], + })); +} + +function exitEntries(): Entry[] { + return EXIT_CODES.map(([code, name, description]) => ({ + id: `exit:${code}`, + table: "exit", + title: `${code} · ${name}`, + subtitle: description, + keys: [String(code), name.toLowerCase()], + keywords: ["exit", "status", "$?"], + tint: code === 0 ? "green" : code > 128 ? "orange" : "red", + copies: [["Copy Code", String(code)]], + facts: [ + ["Code", String(code)], + ["Meaning", name], + ...(code > 128 && code < 255 ? [["Signal", String(code - 128)] as [string, string]] : []), + ], + })); +} + +let cache: Entry[] | undefined; + +/** Every table but chmod, in display order. */ +export function entries(): Entry[] { + cache ??= [ + ...httpEntries(), + ...mimeEntries(), + ...portEntries(), + ...asciiEntries(), + ...entityEntries(), + ...keyEntries(), + ...signalEntries(), + ...exitEntries(), + ]; + return cache; +} + +/** Entries matching every word, exact keys first, then title prefixes. */ +export function search(all: Entry[], query: string): Entry[] { + const q = query.trim().toLowerCase(); + if (!q) return all; + const words = q.split(/\s+/); + const scored: [number, number, Entry][] = []; + all.forEach((e, i) => { + let score = 0; + if (e.keys.some((k) => k.toLowerCase() === q)) score = 100; + else if (query.trim().length === 1 && e.keys.includes(query.trim())) score = 100; + else if (e.title.toLowerCase().startsWith(q)) score = 50; + else { + const hay = `${e.title} ${e.subtitle} ${e.keys.join(" ")} ${e.keywords.join(" ")}`.toLowerCase(); + if (!words.every((w) => hay.includes(w))) return; + score = words.every((w) => e.title.toLowerCase().includes(w)) ? 20 : 10; + } + scored.push([score, i, e]); + }); + return scored.sort((a, b) => b[0] - a[0] || a[1] - b[1]).map((s) => s[2]); +} + +// --------------------------------------------------------------------------- +// chmod + +/** `755`, `0755`, `4755`, `rwxr-x---`, `-rwxr-xr-x`, `u=rwx,g=rx,o=` → mode bits; undefined otherwise. */ +export function parseMode(input: string): number | undefined { + const t = input.trim(); + if (/^(?:0o?)?[0-7]{3,4}$/i.test(t)) { + const digits = t.replace(/^0o/i, ""); + return parseInt(digits.length > 4 ? digits.slice(-4) : digits, 8); + } + const ls = t.match(/^[-dlcbps]?([r-][w-][xsS-])([r-][w-][xsS-])([r-][w-][xtT-])$/); + if (ls) { + let mode = 0; + ls.slice(1).forEach((triple, i) => { + const shift = (2 - i) * 3; + if (triple[0] === "r") mode |= 4 << shift; + if (triple[1] === "w") mode |= 2 << shift; + if ("xst".includes(triple[2])) mode |= 1 << shift; + if ("sStT".includes(triple[2])) mode |= [0o4000, 0o2000, 0o1000][i]; + }); + return mode; + } + if (/^[ugoa]*[=+-][rwxst]*(,[ugoa]*[=+-][rwxst]*)*$/.test(t) && /[=+]/.test(t)) { + let mode = 0; + for (const clause of t.split(",")) { + const [, who, op, perms] = clause.match(/^([ugoa]*)([=+-])([rwxst]*)$/)!; + const classes = !who || who.includes("a") ? "ugo" : who; + for (const c of classes) { + const shift = { u: 6, g: 3, o: 0 }[c as "u" | "g" | "o"]; + let bits = 0; + if (perms.includes("r")) bits |= 4 << shift; + if (perms.includes("w")) bits |= 2 << shift; + if (perms.includes("x")) bits |= 1 << shift; + if (perms.includes("s") && c === "u") bits |= 0o4000; + if (perms.includes("s") && c === "g") bits |= 0o2000; + if (perms.includes("t") && c === "o") bits |= 0o1000; + const mask = (7 << shift) | (c === "u" ? 0o4000 : c === "g" ? 0o2000 : 0o1000); + if (op === "=") mode = (mode & ~mask) | bits; + else if (op === "+") mode |= bits; + else mode &= ~bits; + } + } + return mode; + } + return undefined; +} + +export const octal = (mode: number) => mode.toString(8).padStart(mode > 0o777 ? 4 : 3, "0"); + +/** `rwxr-xr-x`, with s/S/t/T for the special bits. */ +export function symbolic(mode: number): string { + let out = ""; + for (let i = 0; i < 3; i++) { + const shift = (2 - i) * 3; + const special = mode & [0o4000, 0o2000, 0o1000][i]; + out += mode & (4 << shift) ? "r" : "-"; + out += mode & (2 << shift) ? "w" : "-"; + const x = mode & (1 << shift); + out += special ? (i === 2 ? (x ? "t" : "T") : x ? "s" : "S") : x ? "x" : "-"; + } + return out; +} + +/** `u=rwx,g=rx,o=rx` for `chmod`. */ +export function chmodSymbolic(mode: number): string { + return ["u", "g", "o"].map((c, i) => { + const shift = (2 - i) * 3; + let p = ""; + if (mode & (4 << shift)) p += "r"; + if (mode & (2 << shift)) p += "w"; + if (mode & (1 << shift)) p += "x"; + if (mode & [0o4000, 0o2000, 0o1000][i]) p += i === 2 ? "t" : "s"; + return `${c}=${p}`; + }).join(","); +} + +export function permissionWords(mode: number, who: "u" | "g" | "o"): string { + const shift = { u: 6, g: 3, o: 0 }[who]; + const words = [ + ...(mode & (4 << shift) ? ["read"] : []), + ...(mode & (2 << shift) ? ["write"] : []), + ...(mode & (1 << shift) ? ["execute"] : []), + ]; + return words.length ? words.join(", ") : "nothing"; +} + +export function specialBits(mode: number): string[] { + return [ + ...(mode & 0o4000 ? ["setuid"] : []), + ...(mode & 0o2000 ? ["setgid"] : []), + ...(mode & 0o1000 ? ["sticky"] : []), + ]; +} + +/** One entry for a mode: octal, symbolic, per class. */ +export function modeEntry(mode: number, note?: string): Entry { + const o = octal(mode), s = symbolic(mode); + const special = specialBits(mode); + return { + id: `chmod:${o}`, + table: "chmod", + title: `${o} · ${s}`, + subtitle: note ?? + `Owner ${permissionWords(mode, "u")} · group ${permissionWords(mode, "g")} · others ${ + permissionWords(mode, "o") + }`, + keys: [o, s], + keywords: [chmodSymbolic(mode), ...special], + tint: mode & 0o002 ? "red" : mode & 0o7000 ? "orange" : "green", + accessory: special.length ? special.join(", ") : undefined, + copies: [ + ["Copy Octal", o], + ["Copy chmod Command", `chmod ${o} `], + ["Copy Symbolic", s], + ["Copy chmod Symbolic Command", `chmod ${chmodSymbolic(mode)} `], + ], + facts: [ + ["Octal", o], + ["Symbolic", s], + ["ls -l", `-${s}`], + ["chmod", chmodSymbolic(mode)], + ["Owner", permissionWords(mode, "u")], + ["Group", permissionWords(mode, "g")], + ["Others", permissionWords(mode, "o")], + ...(special.length ? [["Special", special.join(", ")] as [string, string]] : []), + ["umask giving it", octal(~mode & 0o777)], + ], + }; +} + +export const commonModes = (): Entry[] => COMMON_MODES.map(([o, note]) => modeEntry(parseInt(o, 8), note)); diff --git a/extensions/dev-reference/lib_test.ts b/extensions/dev-reference/lib_test.ts new file mode 100644 index 0000000..c86a4f7 --- /dev/null +++ b/extensions/dev-reference/lib_test.ts @@ -0,0 +1,146 @@ +import { assertEquals } from "@std/assert"; +import { + chmodSymbolic, + commonModes, + entries, + httpClass, + modeEntry, + octal, + parseMode, + permissionWords, + search, + specialBits, + symbolic, +} from "./lib.ts"; +import { EXIT_CODES, HTTP_STATUS, KEYS, SIGNALS } from "./data.ts"; + +const titles = (query: string, table?: string) => + search(table ? entries().filter((e) => e.table === table) : entries(), query).map((e) => e.title); + +Deno.test("tables are complete and ids are unique", () => { + const all = entries(); + assertEquals(new Set(all.map((e) => e.id)).size, all.length); + assertEquals(all.filter((e) => e.table === "ascii").length, 128); + assertEquals(all.filter((e) => e.table === "keys").length, KEYS.length); + assertEquals(KEYS.filter((k) => k[2].startsWith("Key")).length, 26); + assertEquals(HTTP_STATUS.length > 55 && SIGNALS.length > 30 && EXIT_CODES.length > 25, true); +}); + +Deno.test("search ranks exact keys first", () => { + assertEquals(titles("404")[0], "404 Not Found"); + assertEquals(titles("png", "mime"), ["image/png"]); + assertEquals(titles(".webp")[0], "image/webp"); + assertEquals(titles("5432")[0], "5432 · PostgreSQL"); + assertEquals(titles("sigterm").slice(0, 2), ["15 · SIGTERM", "143 · SIGTERM"]); + assertEquals(titles("term", "signals")[0], "15 · SIGTERM"); + assertEquals(titles("nbsp")[0], "(no-break space)  "); + assertEquals(titles("é")[0], "é é"); + assertEquals(titles("keya")[0], "A"); + assertEquals(titles("KEY_ESC")[0], "Escape"); + assertEquals(titles("137", "exit"), ["137 · SIGKILL"]); + assertEquals(titles("esc", "ascii")[0], "ESC · Escape"); + assertEquals(titles("too many")[0], "429 Too Many Requests"); + assertEquals(titles("postgres mongo"), []); + assertEquals(titles("").length, entries().length); +}); + +Deno.test("entries carry the right facts", () => { + const a = entries().find((e) => e.id === "ascii:65")!; + assertEquals(a.facts, [ + ["Decimal", "65"], + ["Hex", "0x41"], + ["Octal", "0o101"], + ["Binary", "01000001"], + ["HTML", "A"], + ]); + const tab = entries().find((e) => e.id === "ascii:9")!; + assertEquals(tab.title, "HT · Horizontal tab"); + assertEquals(tab.facts.slice(-2), [["Escape", "\\t"], ["Keyboard", "Ctrl+I"]]); + const f12 = entries().find((e) => e.id === "key:F12")!; + assertEquals(f12.facts.slice(2), [["keyCode (legacy)", "123"], ["Linux evdev", "KEY_F12 (88)"], [ + "X11 keycode", + "96", + ]]); + const kp0 = entries().find((e) => e.id === "key:Numpad0")!; + assertEquals(kp0.facts[3], ["Linux evdev", "KEY_KP0 (82)"]); + const kill = entries().find((e) => e.id === "signal:9")!; + assertEquals(kill.copies[1], ["Copy kill Command", "kill -KILL "]); + const euro = entries().find((e) => e.id === "entity:euro")!; + assertEquals(euro.copies, [["Copy Entity", "€"], ["Copy Character", "€"], ["Copy Numeric Entity", "€"]]); + assertEquals([ + httpClass(101).name, + httpClass(204).tint, + httpClass(308).name, + httpClass(429).tint, + httpClass(503).tint, + ], [ + "Informational", + "green", + "Redirection", + "orange", + "red", + ]); +}); + +Deno.test("chmod: parsing every notation", () => { + assertEquals(parseMode("755"), 0o755); + assertEquals(parseMode("0644"), 0o644); + assertEquals(parseMode("0o600"), 0o600); + assertEquals(parseMode("4755"), 0o4755); + assertEquals(parseMode("rwxr-x---"), 0o750); + assertEquals(parseMode("-rw-r--r--"), 0o644); + assertEquals(parseMode("drwxrwxrwt"), 0o1777); + assertEquals(parseMode("rwsr-xr-x"), 0o4755); + assertEquals(parseMode("rwSr--r--"), 0o4644); + assertEquals(parseMode("u=rwx,g=rx,o="), 0o750); + assertEquals(parseMode("a=r"), 0o444); + assertEquals(parseMode("u=rw,go=r"), 0o644); + assertEquals(parseMode("u=rwx,g=rwxs,o=rx"), 0o2775); + assertEquals(parseMode("=rw,o-w"), 0o664); + assertEquals([parseMode("888"), parseMode("75"), parseMode("rwx"), parseMode("hello"), parseMode("u-x")], [ + undefined, + undefined, + undefined, + undefined, + undefined, + ]); +}); + +Deno.test("chmod: formatting", () => { + assertEquals([octal(0o755), octal(0o4755), octal(0o7)], ["755", "4755", "007"]); + assertEquals([ + symbolic(0o755), + symbolic(0o4755), + symbolic(0o4644), + symbolic(0o1777), + symbolic(0o1776), + symbolic(0o2750), + ], [ + "rwxr-xr-x", + "rwsr-xr-x", + "rwSr--r--", + "rwxrwxrwt", + "rwxrwxrwT", + "rwxr-s---", + ]); + assertEquals([chmodSymbolic(0o750), chmodSymbolic(0o2775), chmodSymbolic(0o1777)], [ + "u=rwx,g=rx,o=", + "u=rwx,g=rwxs,o=rx", + "u=rwx,g=rwx,o=rwxt", + ]); + assertEquals([permissionWords(0o750, "u"), permissionWords(0o750, "g"), permissionWords(0o750, "o")], [ + "read, write, execute", + "read, execute", + "nothing", + ]); + assertEquals(specialBits(0o7755), ["setuid", "setgid", "sticky"]); + const e = modeEntry(0o644); + assertEquals([e.title, e.tint], ["644 · rw-r--r--", "green"]); + assertEquals(e.facts.find((f) => f[0] === "umask giving it"), ["umask giving it", "133"]); + assertEquals(modeEntry(0o777).tint, "red"); + // Every symbolic form reads back as the same mode. + for (const m of commonModes()) { + const mode = parseInt(m.keys[0], 8); + assertEquals([parseMode(symbolic(mode)), parseMode(chmodSymbolic(mode))], [mode, mode], m.title); + } +}); diff --git a/extensions/disk-usage/backslash.toml b/extensions/disk-usage/backslash.toml new file mode 100644 index 0000000..8426d8c --- /dev/null +++ b/extensions/disk-usage/backslash.toml @@ -0,0 +1,87 @@ +[extension] +id = "disk-usage" +title = "Disk Usage" +description = "Find what fills your disk: biggest folders and files, caches, trash, logs and containers, with cleanups" +version = "1.0.0" +author = "Backslash" +icon = "bs-drive" +license = "MIT" + +# du, find and df measure (the files are never opened); journalctl, docker and +# podman report their own usage; gio empties the trash and moves files to it; +# flatpak is only probed; gdbus shows a file in the file manager. Cleanups that +# need sudo (journal, package cache) or ask before deleting (docker prune, +# flatpak --unused) run in a terminal, the ones listed last. +[permissions] +run = [ + "du", + "find", + "df", + "journalctl", + "docker", + "podman", + "gio", + "flatpak", + "gdbus", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] +# The home folder is read only to tell files from folders while browsing by size +# (Deno.stat), and ~/.cache to list the cache folders to empty. +read = ["~", "/etc/os-release"] +# Emptying a cache folder deletes what is inside it. +write = ["~/.cache"] +env = ["HOME"] + +[[commands]] +name = "disk-usage" +title = "Disk Usage" +description = "Biggest folders and files in your home, caches, trash, journal and containers" +icon = "bs-drive" +keywords = [ + "disk", + "disque", + "space", + "espace", + "storage", + "stockage", + "du", + "ncdu", + "baobab", + "free space", + "espace libre", + "clean", + "nettoyer", + "cache", + "big files", + "gros fichiers", + "taille", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For cleanups that ask for your password. Leave empty to use the first one installed (kitty, alacritty, GNOME Console, Konsole…)" + +[[preferences]] +name = "timeout" +title = "Scan time limit (seconds)" +description = "The scan stops after this and shows what it found" +type = "number" +default = 60 + +[[preferences]] +name = "minFileMb" +title = "Big files from (MB)" +type = "number" +default = 100 diff --git a/extensions/disk-usage/commands/disk-usage.ts b/extensions/disk-usage/commands/disk-usage.ts new file mode 100644 index 0000000..c77b81a --- /dev/null +++ b/extensions/disk-usage/commands/disk-usage.ts @@ -0,0 +1,605 @@ +import { + type Action, + action, + type Context, + copy, + exec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + storage, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + ago, + bar, + basename, + type BigFile, + biggest, + cacheOwner, + childrenOf, + type ContainerUsage, + type Disk, + type Entry, + formatBytes, + packageCache, + parseDuLine, + rootSize, + safeCache, + shareColor, + tilde, + topFolders, +} from "../lib.ts"; +import { + bigFiles, + containers, + disk, + du, + emptyFolder, + family, + HOME, + installed, + inTerminal, + journalSize, + showInFolder, + sizeOf, + stream as streamLines, + trash, +} from "../system.ts"; + +interface Prefs { + terminal?: string; + timeout?: number; + minFileMb?: number; +} + +interface Extras { + trash?: number; + journal?: number; + docker?: ContainerUsage[]; + podman?: ContainerUsage[]; + pkg?: { name: string; path: string; clean: string[]; size?: number }; + flatpak?: number; +} + +interface Saved { + at: number; + entries: [string, number][]; + files: [string, number, number][]; +} + +const CACHE = `${HOME}/.cache`; +const t = (p: string) => tilde(p, HOME); +const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`; + +function shareMeta(size: number, d?: Disk): Metadata[] { + return d + ? [meta.progress("Of the disk", size / d.size, `${((size / d.size) * 100).toFixed(1)} % of ${formatBytes(d.size)}`)] + : []; +} + +function terminalAction(title: string, argv: string[], prefs: Prefs, o: { icon?: string; confirm?: string } = {}) { + return action(title, async () => { + const term = inTerminal(argv, prefs.terminal); + await toast(`Opened in ${term}`, { message: argv.join(" ") }); + }, { icon: o.icon ?? Icons.terminal, confirm: o.confirm, section: "Clean Up" }); +} + +function fileActions(path: string, isDir: boolean, prefs: Prefs): Action[] { + return [ + open(path, isDir ? "Open Folder" : "Open", { icon: isDir ? Icons.folder : Icons.external }), + ...(isDir + ? [ + action("Show Contents by Size", () => folderView(path, prefs), { + icon: Icons.chartDown, + shortcut: "Return", + }), + ] + : []), + action("Show in Folder", async () => { + await showInFolder(path); + }, { icon: Icons.folder, shortcut: "f" }), + copy(path, "Copy Path", { section: "Copy", shortcut: "c" }), + action("Move to Trash", async () => { + await trash(path); + await toast(`${basename(path)} moved to the trash`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Move ${t(path)} to the trash?`, + }), + ]; +} + +// --------------------------------------------------------------------------- +// Drilling into a folder + +function folderView(root: string, prefs: Prefs): ListSpec { + let status = "Scanning…"; + return list({ + title: t(root), + placeholder: `Filter ${t(root)}…`, + status: () => status, + empty: { title: "Empty folder", icon: Icons.folder }, + async *items({ signal }) { + const d = await disk(root); + const found: Entry[] = []; + const stop = AbortSignal.any([signal, AbortSignal.timeout((prefs.timeout || 60) * 1000)]); + let finished = false; + const scan = stream(root, stop, (e) => found.push(e)).then(() => (finished = true)); + const render = async () => { + const total = rootSize(found, root); + const kids = found.filter((e) => e.path !== root).sort((a, b) => b.size - a.size).slice(0, 80); + status = `${finished ? "" : "Scanning… "}${formatBytes(total)} in ${plural(kids.length, "item")}${ + finished && stop.aborted && !signal.aborted ? " · stopped at the time limit" : "" + }`; + return await Promise.all(kids.map(async (e) => { + const isDir = (await Deno.stat(e.path).catch(() => undefined))?.isDirectory ?? false; + const ratio = total ? e.size / total : 0; + return { + id: e.path, + title: basename(e.path), + icon: { source: isDir ? Icons.folder : Icons.file, tint: shareColor(ratio) }, + keywords: [e.path], + accessories: [{ text: `${Math.round(ratio * 100)} %`, color: "gray" as const }, { + text: formatBytes(e.size), + }], + detail: { + stats: [{ label: "Size", value: formatBytes(e.size), color: shareColor(ratio) }, { + label: `Of ${basename(root)}`, + value: `${Math.round(ratio * 100)} %`, + }], + metadata: [ + meta.progress(`Of ${basename(root)}`, ratio), + ...shareMeta(e.size, d), + meta.text("Path", t(e.path), { icon: isDir ? Icons.folder : Icons.file }), + ], + }, + actions: fileActions(e.path, isDir, prefs), + } satisfies Item; + })); + }; + while (!finished) { + await Promise.race([scan, new Promise((r) => setTimeout(r, 700))]); + yield await render(); + } + }, + }); +} + +/** `du -a -d 1`: the files and folders right inside. */ +function stream(root: string, signal: AbortSignal, onEntry: (e: Entry) => void) { + return streamLines("du", ["-x", "-a", "-d", "1", "-b", root], signal, (l) => { + const e = parseDuLine(l); + if (e) onEntry(e); + }); +} + +// --------------------------------------------------------------------------- +// Overview + +function diskItem(d: Disk | undefined, home: number, scanning: boolean, savedAt?: number): Item { + const ratio = d ? d.used / d.size : 0; + return { + id: "disk", + title: d ? `${formatBytes(d.available)} free` : "Home folder", + subtitle: d ? `of ${formatBytes(d.size)} on ${d.mount}` : undefined, + icon: { source: Icons.drive, tint: ratio > 0.9 ? "red" : ratio > 0.75 ? "orange" : "green" }, + section: "Disk", + keywords: ["disk", "disque", "free", "libre", "space", "espace"], + accessories: [{ text: `${Math.round(ratio * 100)} % used`, color: ratio > 0.9 ? "red" : undefined }], + detail: { + stats: d + ? [ + { + label: "Used", + value: formatBytes(d.used), + color: ratio > 0.9 ? "red" : ratio > 0.75 ? "orange" : undefined, + }, + { label: "Free", value: formatBytes(d.available), color: "green" }, + { label: "Total", value: formatBytes(d.size) }, + ] + : [], + metadata: [ + ...(d ? [meta.progress("Disk used", ratio, `${formatBytes(d.used)} · ${Math.round(ratio * 100)} %`)] : []), + ...(home ? [meta.progress("Home folder", d ? home / d.size : 0, formatBytes(home))] : []), + meta.separator(), + meta.text("Mount point", d?.mount ?? "?", { icon: Icons.drive }), + meta.text("Home", t(HOME) || HOME, { icon: Icons.home }), + meta.text("Scan", scanning ? "In progress…" : savedAt ? `Saved ${ago(savedAt)}` : "Done", { + icon: scanning ? Icons.hourglass : Icons.check, + }), + ], + }, + actions: [ + action("Rescan", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + open(HOME, "Open Home Folder", { icon: Icons.home }), + ], + }; +} + +function folderItem(e: Entry, entries: Entry[], d: Disk | undefined, home: number, prefs: Prefs): Item { + const ratio = d ? e.size / d.size : 0; + const kids = childrenOf(entries, e.path); + const md = kids.length + ? `**Biggest inside**\n\n${ + kids.map((k) => `\`${bar(e.size ? k.size / e.size : 0, 12)}\` ${formatBytes(k.size)} · ${basename(k.path)}`).join( + "\n\n", + ) + }` + : undefined; + return { + id: `folder:${e.path}`, + title: basename(e.path), + subtitle: t(e.path), + icon: { source: Icons.folder, tint: shareColor(ratio) }, + section: "Biggest Folders", + keywords: [e.path, "folder", "dossier"], + accessories: [ + { text: `${home ? Math.round((e.size / home) * 100) : 0} %`, color: "gray", tooltip: "Of your home folder" }, + { text: formatBytes(e.size) }, + ], + detail: { + stats: [ + { label: "Size", value: formatBytes(e.size), color: shareColor(ratio) }, + { label: "Of home", value: `${home ? Math.round((e.size / home) * 100) : 0} %` }, + ], + markdown: md, + metadata: [ + ...shareMeta(e.size, d), + ...(home ? [meta.progress("Of home", e.size / home)] : []), + meta.text("Path", t(e.path), { icon: Icons.folder }), + ], + }, + actions: fileActions(e.path, true, prefs).filter((a) => a.title !== "Move to Trash"), + }; +} + +function fileItem(f: BigFile, d: Disk | undefined, prefs: Prefs): Item { + const ratio = d ? f.size / d.size : 0; + return { + id: `file:${f.path}`, + title: basename(f.path), + subtitle: t(f.path.split("/").slice(0, -1).join("/")), + icon: { source: Icons.file, tint: shareColor(ratio) }, + section: "Biggest Files", + keywords: [f.path, "file", "fichier"], + accessories: [{ text: ago(f.modified), color: "gray" }, { text: formatBytes(f.size) }], + detail: { + stats: [{ label: "Size", value: formatBytes(f.size), color: shareColor(ratio) }, { + label: "Modified", + value: ago(f.modified), + }], + metadata: [ + ...shareMeta(f.size, d), + meta.text("Path", t(f.path), { icon: Icons.file }), + meta.text("Modified", new Date(f.modified).toLocaleString(), { icon: Icons.clock }), + ], + }, + actions: fileActions(f.path, false, prefs), + }; +} + +function cacheItem(e: Entry, d: Disk | undefined, prefs: Prefs): Item { + const name = basename(e.path); + const owner = cacheOwner(name); + const safe = safeCache(name); + return { + id: `cache:${e.path}`, + title: name, + subtitle: owner, + icon: { source: Icons.archive, tint: safe ? "teal" : "orange" }, + section: "Caches", + keywords: ["cache", e.path, owner ?? ""], + accessories: [ + ...(safe ? [] : [{ tag: "Re-download", color: "orange" as const }]), + { text: formatBytes(e.size) }, + ], + detail: { + stats: [{ label: "Size", value: formatBytes(e.size) }], + markdown: safe + ? "Apps rebuild their cache as needed. Emptying it frees the space now; the app may be slower the first time." + : "This folder holds downloads (models, browsers) that take long to get back.", + metadata: [ + ...shareMeta(e.size, d), + meta.text("Path", t(e.path), { icon: Icons.folder }), + ...(owner ? [meta.text("Used by", owner, { icon: Icons.apps })] : []), + ], + }, + actions: [ + action(`Empty ${name}`, async () => { + await emptyFolder(e.path); + await toast(`Freed about ${formatBytes(e.size)}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete everything in ${t(e.path)} (${formatBytes(e.size)})? Close ${owner ?? "the app"} first.`, + }), + action("Show Contents by Size", () => folderView(e.path, prefs), { + icon: Icons.chartDown, + shortcut: "Return", + }), + open(e.path, "Open Folder", { icon: Icons.folder }), + copy(e.path, "Copy Path", { section: "Copy", shortcut: "c" }), + ], + }; +} + +function cleanupItems(x: Extras, caches: Entry[], d: Disk | undefined, prefs: Prefs): Item[] { + const out: Item[] = []; + const cleanup = ( + id: string, + title: string, + size: number | undefined, + icon: string, + md: string, + actions: Action[], + extra: Metadata[] = [], + ): Item => ({ + id: `cleanup:${id}`, + title, + subtitle: size === undefined ? "Measuring…" : undefined, + icon: { source: icon, tint: size && size > 1024 ** 3 ? "orange" : "teal" }, + section: "Clean Up", + keywords: ["clean", "nettoyer", "free", "libérer", id], + accessories: size === undefined ? [] : [{ text: formatBytes(size) }], + detail: { + stats: size === undefined ? [] : [{ label: "Size", value: formatBytes(size) }], + markdown: md, + metadata: [...(size ? shareMeta(size, d) : []), ...extra], + }, + actions, + }); + + out.push(cleanup( + "trash", + "Trash", + x.trash, + Icons.trash, + "Files you deleted are kept here until you empty it.", + [ + action("Empty Trash", async () => { + await exec("gio", ["trash", "--empty"]); + await toast("Trash emptied", { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete everything in the trash for good${x.trash ? ` (${formatBytes(x.trash)})` : ""}?`, + }), + open("trash:///", "Open Trash", { icon: Icons.folder }), + ], + )); + + const cacheTotal = caches.reduce((s, c) => s + c.size, 0); + const safe = caches.filter((c) => safeCache(basename(c.path))); + out.push(cleanup( + "cache", + "App Caches", + caches.length ? cacheTotal : undefined, + Icons.archive, + `**${plural(caches.length, "folder")}** in \`~/.cache\`. Emptying them is safe: apps rebuild what they need.`, + [ + action("Empty All Caches", async () => { + for (const c of safe) await emptyFolder(c.path); + await toast(`Freed about ${formatBytes(safe.reduce((s, c) => s + c.size, 0))}`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Empty ${plural(safe.length, "cache folder")} (${ + formatBytes(safe.reduce((s, c) => s + c.size, 0)) + })? Close your apps first.`, + }), + open(CACHE, "Open ~/.cache", { icon: Icons.folder }), + ], + [meta.tags("Biggest", caches.slice(0, 5).map((c) => basename(c.path)))], + )); + + if (x.journal !== undefined) { + out.push(cleanup( + "journal", + "System Journal", + x.journal, + Icons.book, + "Logs kept by systemd. Vacuuming removes the oldest ones; it asks for your password in a terminal.", + [ + terminalAction("Vacuum to 200 MB", ["sudo", "journalctl", "--vacuum-size=200M"], prefs), + submenu( + "Vacuum…", + [ + terminalAction("Keep 100 MB", ["sudo", "journalctl", "--vacuum-size=100M"], prefs), + terminalAction("Keep 500 MB", ["sudo", "journalctl", "--vacuum-size=500M"], prefs), + terminalAction("Keep 2 Weeks", ["sudo", "journalctl", "--vacuum-time=2weeks"], prefs), + terminalAction("Keep 1 Month", ["sudo", "journalctl", "--vacuum-time=1month"], prefs), + ], + { icon: Icons.terminal, section: "Clean Up" }, + ), + ], + )); + } + + for (const tool of ["docker", "podman"] as const) { + const usage = x[tool]; + if (!usage) continue; + const size = usage.reduce((s, u) => s + u.size, 0); + const reclaim = usage.reduce((s, u) => s + u.reclaimable, 0); + out.push(cleanup( + tool, + tool === "docker" ? "Docker" : "Podman", + size, + Icons.box, + `**${ + formatBytes(reclaim) + }** can be reclaimed: stopped containers, unused networks, dangling images and build cache.`, + [ + terminalAction("Prune Unused Data", [tool, "system", "prune"], prefs, { icon: Icons.trash }), + terminalAction("Prune Everything Unused, Images Included", [tool, "system", "prune", "-a"], prefs, { + icon: Icons.trash, + }), + ], + usage.map((u) => + meta.text( + u.type, + `${formatBytes(u.size)} · ${u.total} (${u.active} active) · ${formatBytes(u.reclaimable)} reclaimable`, + { + icon: Icons.layers, + }, + ) + ), + )); + } + + if (x.pkg) { + out.push(cleanup( + "packages", + x.pkg.name, + x.pkg.size, + Icons.package, + `Downloaded packages kept in \`${x.pkg.path}\` after installing. Cleaning asks for your password in a terminal.`, + [terminalAction(`Clean ${x.pkg.name}`, x.pkg.clean, prefs, { icon: Icons.trash })], + [meta.text("Command", x.pkg.clean.join(" "), { icon: Icons.terminal })], + )); + } + + if (x.flatpak !== undefined) { + out.push(cleanup( + "flatpak", + "Unused Flatpak Runtimes", + x.flatpak, + Icons.layers, + "Flatpak keeps runtimes after the apps that needed them are gone. The terminal lists them before removing anything.", + [terminalAction("Remove Unused Runtimes", ["flatpak", "uninstall", "--unused"], prefs, { icon: Icons.trash })], + [meta.text("Size", "All of Flatpak, apps included", { icon: Icons.info })], + )); + } + return out; +} + +let status = ""; + +export default (ctx: Context) => { + const prefs = ctx.preferences as Prefs; + return list({ + placeholder: "Filter folders, files and caches…", + filter: { + options: [ + { value: "all", title: "Overview" }, + { value: "folders", title: "Folders" }, + { value: "files", title: "Files" }, + { value: "cleanup", title: "Clean Up" }, + ], + }, + empty: { title: "Nothing here yet", description: "Still scanning, or nothing big enough", icon: Icons.drive }, + status: () => status, + async *items({ signal, filter }) { + const f = filter ?? "all"; + const fam = await family(); + const [d, saved] = await Promise.all([ + disk(HOME), + (storage.get("scan") as Promise).then((v) => v as Saved | undefined).catch(() => undefined), + ]); + const entries = new Map(); + const files: BigFile[] = []; + const x: Extras = {}; + let scanning = true; + let savedAt: number | undefined; + + if (saved && saved.entries?.length) { + for (const [p, s] of saved.entries) entries.set(p, s); + for (const [p, s, m] of saved.files ?? []) files.push({ path: p, size: s, modified: m }); + savedAt = saved.at; + } + + const render = (): Item[] => { + const list = [...entries].map(([path, size]) => ({ path, size })); + const home = rootSize(list, HOME); + const caches = childrenOf(list, CACHE, 40).filter((c) => c.size >= 1024); + const out: Item[] = []; + if (f === "all") out.push(diskItem(d, home, scanning, savedAt)); + if (f === "all" || f === "cleanup") out.push(...cleanupItems(x, caches, d, prefs)); + if (f === "all" || f === "folders") { + out.push(...topFolders(list, HOME, f === "all" ? 12 : 40).map((e) => folderItem(e, list, d, home, prefs))); + } + if (f === "all" || f === "files") { + out.push(...biggest(files, f === "all" ? 12 : 50).map((b) => fileItem(b, d, prefs))); + } + if (f === "cleanup" || f === "folders") out.push(...caches.map((c) => cacheItem(c, d, prefs))); + status = `${scanning ? (savedAt ? `Last scan ${ago(savedAt)}, rescanning… ` : "Scanning… ") : ""}${ + home ? `Home ${formatBytes(home)}` : "" + }${d ? ` · ${formatBytes(d.available)} free of ${formatBytes(d.size)}` : ""}`; + return out; + }; + yield render(); + + // The scan replaces what was saved. + const stop = AbortSignal.any([signal, AbortSignal.timeout((prefs.timeout || 60) * 1000)]); + const fresh = new Map(); + const freshFiles: BigFile[] = []; + let first = true; + const swap = () => { + // Keep the saved results on screen until the scan has something to show. + if (first && !fresh.size && !freshFiles.length) return; + first = false; + savedAt = undefined; + entries.clear(); + for (const [p, s] of fresh) entries.set(p, s); + files.splice(0, files.length, ...freshFiles); + }; + const pkg = packageCache(fam); + const jobs = [ + du(HOME, 2, stop, (e) => fresh.set(e.path, e.size)), + bigFiles(HOME, prefs.minFileMb || 100, stop, (b) => freshFiles.push(b)), + sizeOf(`${HOME}/.local/share/Trash`).then((s) => (x.trash = s ?? 0)), + journalSize().then((s) => (x.journal = s)), + containers("docker").then((u) => (x.docker = u)), + containers("podman").then((u) => (x.podman = u)), + ...(pkg ? [sizeOf(pkg.path).then((s) => (x.pkg = s === undefined ? undefined : { ...pkg, size: s }))] : []), + installed("flatpak").then(async (yes) => { + if (!yes) return; + const [sys, user] = await Promise.all([sizeOf("/var/lib/flatpak"), sizeOf(`${HOME}/.local/share/flatpak`)]); + x.flatpak = (sys ?? 0) + (user ?? 0); + }), + ].map((p) => p.catch(() => {})); + const all = Promise.all(jobs); + let done = false; + all.then(() => (done = true)); + while (!done && !signal.aborted) { + await Promise.race([all, new Promise((r) => setTimeout(r, 800))]); + swap(); + if (!done) yield render(); + } + if (signal.aborted) return; + scanning = false; + swap(); + const final = render(); + const partial = stop.aborted; + if (partial) status += " · stopped at the time limit"; + yield final; + const homeSize = fresh.get(HOME); + if (homeSize !== undefined || fresh.size) { + updateCommandMetadata({ + subtitle: d ? `${formatBytes(d.available)} free` : homeSize ? `Home ${formatBytes(homeSize)}` : null, + }).catch(() => {}); + } + // Saved for next time: the top-level folders, their children, and the big files. + const keep = [...fresh].sort((a, b) => b[1] - a[1]).slice(0, 400); + await storage.set("scan", { + at: Date.now(), + entries: keep, + files: biggest(freshFiles, 60).map((b) => [b.path, b.size, b.modified]), + }).catch(() => {}); + }, + }); +}; diff --git a/extensions/disk-usage/lib.ts b/extensions/disk-usage/lib.ts new file mode 100644 index 0000000..92e5c60 --- /dev/null +++ b/extensions/disk-usage/lib.ts @@ -0,0 +1,241 @@ +// Parsing du, df, find, journalctl and docker output, and choosing what to +// show. Pure, tested in lib_test.ts. + +export interface Entry { + path: string; + size: number; +} + +/** One `du -b` line: `123456\t/home/ada/.cache`. */ +export function parseDuLine(line: string): Entry | undefined { + const m = line.match(/^(\d+)\t(.+)$/); + return m ? { size: Number(m[1]), path: m[2] } : undefined; +} + +export function parseDu(text: string): Entry[] { + return text.split("\n").map(parseDuLine).filter((e): e is Entry => !!e); +} + +export interface Disk { + size: number; + used: number; + available: number; + mount: string; +} + +/** `df -B1 --output=size,used,avail,target `. */ +export function parseDf(text: string): Disk | undefined { + const line = text.trim().split("\n").slice(1).find((l) => l.trim()); + const m = line?.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/); + return m ? { size: +m[1], used: +m[2], available: +m[3], mount: m[4] } : undefined; +} + +/** `journalctl --disk-usage`: "Archived and active journals take up 1.2G in the file system." */ +export function parseJournalUsage(text: string): number | undefined { + const m = text.match(/take up ([\d.]+)\s*([KMGTP]?)i?B?\b/i); + if (!m) return undefined; + const pow = m[2] ? "KMGTP".indexOf(m[2].toUpperCase()) + 1 : 0; + return Math.round(parseFloat(m[1]) * 1024 ** pow); +} + +/** "2.5GB", "512.3MB", "0B", "1.03kB" (docker, decimal units) → bytes. */ +export function parseHumanSize(text: string): number { + const m = text.trim().match(/^([\d.]+)\s*([kKMGTP]?)i?B?/); + if (!m) return 0; + const pow = m[2] ? "KMGTP".indexOf(m[2].toUpperCase()) + 1 : 0; + return Math.round(parseFloat(m[1]) * 1000 ** pow); +} + +export interface ContainerUsage { + type: string; + total: number; + active: number; + size: number; + reclaimable: number; +} + +/** `docker system df --format '{{json .}}'` (podman prints the same fields). */ +export function parseSystemDf(text: string): ContainerUsage[] { + return text.split("\n").filter((l) => l.trim().startsWith("{")).map((l) => { + const o = JSON.parse(l) as Record; + return { + type: String(o.Type ?? ""), + total: Number(o.TotalCount ?? o.Total ?? 0), + active: Number(o.Active ?? 0), + size: typeof o.Size === "number" ? o.Size : parseHumanSize(String(o.Size ?? "0")), + reclaimable: typeof o.Reclaimable === "number" + ? o.Reclaimable + : parseHumanSize(String(o.Reclaimable ?? "0").replace(/\s*\(.*\)/, "")), + }; + }); +} + +export interface BigFile extends Entry { + modified: number; +} + +/** One `find -printf '%s\t%T@\t%p\n'` line. */ +export function parseFindLine(line: string): BigFile | undefined { + const m = line.match(/^(\d+)\t([\d.]+)\t(.+)$/); + return m ? { size: +m[1], modified: Math.round(parseFloat(m[2]) * 1000), path: m[3] } : undefined; +} + +// --------------------------------------------------------------------------- +// Choosing + +const depth = (path: string, root: string) => + path === root ? 0 : path.slice(root.length).split("/").filter(Boolean).length; + +/** The folders right under `root`, biggest first. */ +export function topFolders(entries: Entry[], root: string, limit = 20): Entry[] { + return entries.filter((e) => e.path.startsWith(`${root}/`) && depth(e.path, root) === 1) + .sort((a, b) => b.size - a.size).slice(0, limit); +} + +/** The biggest subfolders of `parent` among the entries. */ +export function childrenOf(entries: Entry[], parent: string, limit = 6): Entry[] { + return entries.filter((e) => e.path.startsWith(`${parent}/`) && !e.path.slice(parent.length + 1).includes("/")) + .sort((a, b) => b.size - a.size).slice(0, limit); +} + +/** Total of the root from du's last line, or the sum of its children while scanning. */ +export function rootSize(entries: Entry[], root: string): number { + const own = entries.find((e) => e.path === root); + return own?.size ?? topFolders(entries, root, Infinity).reduce((s, e) => s + e.size, 0); +} + +/** Keeps the biggest files, without duplicates. */ +export function biggest(files: T[], limit = 25): T[] { + const seen = new Set(); + return [...files].sort((a, b) => b.size - a.size).filter((f) => !seen.has(f.path) && seen.add(f.path)).slice( + 0, + limit, + ); +} + +/** What a cache folder belongs to, for a friendlier subtitle. */ +export function cacheOwner(name: string): string | undefined { + const known: Record = { + "mozilla": "Firefox", + "google-chrome": "Google Chrome", + "chromium": "Chromium", + "BraveSoftware": "Brave", + "thumbnails": "Thumbnails", + "pip": "Python pip", + "pypoetry": "Poetry", + "uv": "uv (Python)", + "yarn": "Yarn", + "pnpm": "pnpm", + "deno": "Deno", + "go-build": "Go build cache", + "JetBrains": "JetBrains IDEs", + "spotify": "Spotify", + "tracker3": "GNOME search index", + "fontconfig": "Fonts", + "mesa_shader_cache": "GPU shaders", + "nvidia": "NVIDIA shaders", + "yay": "yay (AUR)", + "paru": "paru (AUR)", + "flatpak": "Flatpak", + "electron": "Electron", + "ms-playwright": "Playwright browsers", + "huggingface": "Hugging Face models", + "torch": "PyTorch", + "bazel": "Bazel", + "ccache": "ccache", + "sccache": "sccache", + "cargo": "Cargo", + "npm": "npm", + "node-gyp": "node-gyp", + "gstreamer-1.0": "GStreamer", + "babl": "GIMP", + "gimp": "GIMP", + "steam": "Steam", + "vscode-cpptools": "VS Code C++", + "typescript": "TypeScript", + }; + return known[name]; +} + +/** A cache folder that is safe to empty: everything under ~/.cache is, by definition, except a few. */ +export function safeCache(name: string): boolean { + // Some apps keep things there that aren't recreated without a download or a long rebuild. + return !["ms-playwright", "huggingface", "torch"].includes(name); +} + +// --------------------------------------------------------------------------- +// Package caches + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +/** Where the distribution keeps downloaded packages, and how to clean it. */ +export function packageCache(f?: Family): { name: string; path: string; clean: string[] } | undefined { + switch (f) { + case "arch": + return { + name: "pacman cache", + path: "/var/cache/pacman/pkg", + clean: ["sudo", "pacman", "-Sc"], + }; + case "debian": + return { name: "APT cache", path: "/var/cache/apt/archives", clean: ["sudo", "apt-get", "clean"] }; + case "fedora": + return { name: "DNF cache", path: "/var/cache/libdnf5", clean: ["sudo", "dnf", "clean", "all"] }; + case "suse": + return { name: "Zypper cache", path: "/var/cache/zypp/packages", clean: ["sudo", "zypper", "clean", "--all"] }; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Formatting + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); +export const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() ?? p; +export const dirname = (p: string) => p.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "/"; + +/** "3 days ago", "2 months ago". */ +export function ago(ms: number, now = Date.now()): string { + const d = Math.floor((now - ms) / 86400000); + if (d < 1) return "today"; + if (d === 1) return "yesterday"; + if (d < 31) return `${d} days ago`; + const m = Math.floor(d / 30.4); + if (m < 12) return `${m} month${m === 1 ? "" : "s"} ago`; + const y = Math.floor(d / 365); + return `${y} year${y === 1 ? "" : "s"} ago`; +} + +/** Color of a share of the disk. */ +export function shareColor(ratio: number): "red" | "orange" | "yellow" | "blue" { + return ratio >= 0.25 ? "red" : ratio >= 0.1 ? "orange" : ratio >= 0.03 ? "yellow" : "blue"; +} + +/** A tiny text bar for the detail panel: `▓▓▓▓░░░░░░`. */ +export function bar(ratio: number, width = 20): string { + const n = Math.max(0, Math.min(width, Math.round(ratio * width))); + return "▓".repeat(n) + "░".repeat(width - n); +} diff --git a/extensions/disk-usage/lib_test.ts b/extensions/disk-usage/lib_test.ts new file mode 100644 index 0000000..8dc19a6 --- /dev/null +++ b/extensions/disk-usage/lib_test.ts @@ -0,0 +1,121 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + bar, + biggest, + cacheOwner, + childrenOf, + formatBytes, + packageCache, + parseDf, + parseDu, + parseFindLine, + parseHumanSize, + parseJournalUsage, + parseSystemDf, + rootSize, + safeCache, + shareColor, + tilde, + topFolders, +} from "./lib.ts"; +import { shellLine, terminalArgs } from "./terminal.ts"; + +// `du -x -d 2 -b /home/ada`, as du prints it: children before parents. +const DU = `4096\t/home/ada/.cache/fontconfig +1288490188\t/home/ada/.cache/mozilla +524288000\t/home/ada/.cache/pip +1813512284\t/home/ada/.cache +8589934592\t/home/ada/.local/share +120000\t/home/ada/.local/state +8590054592\t/home/ada/.local +21474836480\t/home/ada/Videos/Holidays +22548578304\t/home/ada/Videos +3221225472\t/home/ada/Downloads +36173373992\t/home/ada +`; + +Deno.test("du output", () => { + const entries = parseDu(DU); + assertEquals(entries.length, 11); + assertEquals(topFolders(entries, "/home/ada").map((e) => e.path), [ + "/home/ada/Videos", + "/home/ada/.local", + "/home/ada/Downloads", + "/home/ada/.cache", + ]); + assertEquals(childrenOf(entries, "/home/ada/.cache").map((e) => e.path), [ + "/home/ada/.cache/mozilla", + "/home/ada/.cache/pip", + "/home/ada/.cache/fontconfig", + ]); + assertEquals(rootSize(entries, "/home/ada"), 36173373992); + // While scanning, before du prints the root: the sum of what is known. + assertEquals(rootSize(entries.slice(0, 4), "/home/ada"), 1813512284); + // A tab in a file name stays in the path. + assertEquals(parseDu("12\t/home/ada/a\tb\n")[0].path, "/home/ada/a\tb"); +}); + +Deno.test("df, journal and container usage", () => { + assertEquals( + parseDf(" 1B-blocks Used Avail Mounted on\n510770802688 312345067520 172421853184 /home\n"), + { size: 510770802688, used: 312345067520, available: 172421853184, mount: "/home" }, + ); + assertEquals(parseDf(""), undefined); + assertEquals( + parseJournalUsage("Archived and active journals take up 1.2G in the file system."), + Math.round(1.2 * 1024 ** 3), + ); + assertEquals(parseJournalUsage("Archived and active journals take up 24.0M in the file system."), 24 * 1024 ** 2); + assertEquals(parseJournalUsage("No journal files were found."), undefined); + assertEquals(parseHumanSize("2.5GB"), 2_500_000_000); + assertEquals(parseHumanSize("0B"), 0); + const df = parseSystemDf( + [ + '{"Active":"3","Reclaimable":"4.1GB (61%)","Size":"6.72GB","TotalCount":"12","Type":"Images"}', + '{"Active":"2","Reclaimable":"0B (0%)","Size":"12.3MB","TotalCount":"4","Type":"Containers"}', + '{"Active":"1","Reclaimable":"55MB","Size":"310MB","TotalCount":"3","Type":"Local Volumes"}', + '{"Active":"0","Reclaimable":"1.2GB","Size":"1.2GB","TotalCount":"40","Type":"Build Cache"}', + ].join("\n"), + ); + assertEquals(df[0], { type: "Images", total: 12, active: 3, size: 6_720_000_000, reclaimable: 4_100_000_000 }); + assertEquals(df.reduce((s, u) => s + u.reclaimable, 0), 5_355_000_000); +}); + +Deno.test("big files from find", () => { + const a = parseFindLine("4294967296\t1729000000.1234567890\t/home/ada/VMs/win11.qcow2")!; + assertEquals(a, { size: 4294967296, modified: 1729000000123, path: "/home/ada/VMs/win11.qcow2" }); + assertEquals(parseFindLine("garbage"), undefined); + const files = [a, { ...a, size: 1, path: "/x" }, a, { ...a, size: 9e9, path: "/y" }]; + assertEquals(biggest(files).map((f) => f.path), ["/y", "/home/ada/VMs/win11.qcow2", "/x"]); +}); + +Deno.test("caches and package caches", () => { + assertEquals(cacheOwner("mozilla"), "Firefox"); + assertEquals(cacheOwner("something"), undefined); + assertEquals(safeCache("pip"), true); + assertEquals(safeCache("huggingface"), false); + assertEquals(packageCache("arch")?.path, "/var/cache/pacman/pkg"); + assertEquals(packageCache("debian")?.clean, ["sudo", "apt-get", "clean"]); + assertEquals(packageCache(undefined), undefined); +}); + +Deno.test("formatting", () => { + assertEquals(formatBytes(1813512284), "1.7 GB"); + assertEquals(formatBytes(512), "512 B"); + assertEquals(tilde("/home/ada/Videos", "/home/ada"), "~/Videos"); + assertEquals(bar(0.25, 8), "▓▓░░░░░░"); + assertEquals(shareColor(0.3), "red"); + assertEquals(shareColor(0.01), "blue"); + const now = Date.UTC(2026, 9, 28); + assertEquals(ago(now - 3 * 86400000, now), "3 days ago"); + assertEquals(ago(now - 70 * 86400000, now), "2 months ago"); + assertEquals(ago(now - 800 * 86400000, now), "2 years ago"); +}); + +Deno.test("terminal command lines", () => { + assertEquals(terminalArgs("kitty", ["sh", "-c", "x"]), ["sh", "-c", "x"]); + assertEquals(terminalArgs("gnome-terminal", ["sh"]), ["--", "sh"]); + assertEquals(shellLine(["sudo", "journalctl", "--vacuum-size=200M"]), "sudo journalctl --vacuum-size=200M"); + assertEquals(shellLine(["echo", "it's"]), `echo 'it'\\''s'`); +}); diff --git a/extensions/disk-usage/system.ts b/extensions/disk-usage/system.ts new file mode 100644 index 0000000..8544432 --- /dev/null +++ b/extensions/disk-usage/system.ts @@ -0,0 +1,151 @@ +// Running the scanners and the cleanups. + +import { exec, openExternal } from "@backslash/api"; +import { + type BigFile, + type ContainerUsage, + type Disk, + type Entry, + osFamily, + parseDf, + parseDu, + parseDuLine, + parseFindLine, + parseJournalUsage, + parseSystemDf, +} from "./lib.ts"; +import { runInTerminal, shellLine } from "./terminal.ts"; + +export const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +/** Runs a program and calls `onLine` for each line of its output as it comes. Stops at `signal`. */ +export async function stream( + program: string, + args: string[], + signal: AbortSignal, + onLine: (line: string) => void, +): Promise { + const child = new Deno.Command(program, { args, stdin: "null", stdout: "piped", stderr: "null", signal }).spawn(); + let rest = ""; + try { + for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { + const lines = (rest + chunk).split("\n"); + rest = lines.pop() ?? ""; + for (const l of lines) onLine(l); + } + if (rest) onLine(rest); + } catch { + // Aborted: keep what was read. + } + await child.status.catch(() => {}); +} + +/** `du -x -d -b `, entries streamed as du finishes each folder. */ +export function du(path: string, depth: number, signal: AbortSignal, onEntry: (e: Entry) => void) { + return stream("du", ["-x", "-d", String(depth), "-b", path], signal, (l) => { + const e = parseDuLine(l); + if (e) onEntry(e); + }); +} + +/** Files over `minMb` MB on the same file system, as `find` meets them. */ +export function bigFiles(path: string, minMb: number, signal: AbortSignal, onFile: (f: BigFile) => void) { + return stream( + "find", + [path, "-xdev", "-type", "f", "-size", `+${minMb}M`, "-printf", "%s\\t%T@\\t%p\\n"], + signal, + (l) => { + const f = parseFindLine(l); + if (f) onFile(f); + }, + ); +} + +const quiet = (p: Promise) => p.catch(() => undefined); + +export const disk = async (path: string): Promise => + parseDf((await quiet(exec("df", ["-B1", "--output=size,used,avail,target", path]))) ?? ""); + +/** Size of one folder (or 0 when it doesn't exist), within `ms`. */ +export async function sizeOf(path: string, ms = 15000): Promise { + const out = await quiet(exec("du", ["-s", "-x", "-b", path], { signal: AbortSignal.timeout(ms) })); + return out === undefined ? undefined : parseDu(out)[0]?.size ?? 0; +} + +export const journalSize = async () => + parseJournalUsage((await quiet(exec("journalctl", ["--disk-usage"], { signal: AbortSignal.timeout(5000) }))) ?? ""); + +export async function containers(tool: "docker" | "podman"): Promise { + const out = await quiet( + exec(tool, ["system", "df", "--format", "{{json .}}"], { signal: AbortSignal.timeout(8000) }), + ); + if (out === undefined) return undefined; + try { + return parseSystemDf(out); + } catch { + return undefined; + } +} + +export async function installed(program: string, args = ["--version"]): Promise { + try { + await exec(program, args, { signal: AbortSignal.timeout(3000) }); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.NotCapable || + e instanceof Deno.errors.PermissionDenied); + } +} + +/** Deletes what is inside a folder, keeping the folder. Returns the bytes freed (as du counted them). */ +export async function emptyFolder(path: string): Promise { + for await (const e of Deno.readDir(path)) { + await Deno.remove(`${path}/${e.name}`, { recursive: true }).catch(() => {}); + } +} + +/** Runs a command in a terminal that stays open on the result (sudo asks there). */ +export function inTerminal(argv: string[], terminal?: string): string { + const script = `${shellLine(argv)}; echo; printf 'Done. Press Enter to close.'; read _`; + return runInTerminal(["sh", "-c", script], terminal || undefined); +} + +/** Opens the folder with the file selected (FileManager1), or the folder. */ +export async function showInFolder(path: string): Promise { + const uri = `file://${path.split("/").map(encodeURIComponent).join("/")}`; + try { + await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + `['${uri.replace(/'/g, "%27")}']`, + "", + ]); + } catch { + await openExternal(path.split("/").slice(0, -1).join("/") || "/"); + } +} + +export async function trash(path: string): Promise { + try { + await exec("gio", ["trash", path]); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("gio (GLib) is needed to move files to the trash"); + throw e; + } +} diff --git a/extensions/disk-usage/terminal.ts b/extensions/disk-usage/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/disk-usage/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/downloads/backslash.toml b/extensions/downloads/backslash.toml new file mode 100644 index 0000000..9525785 --- /dev/null +++ b/extensions/downloads/backslash.toml @@ -0,0 +1,37 @@ +[extension] +id = "downloads" +title = "Downloads" +description = "Recent downloads with previews and where they came from; open, copy, move, rename or trash them" +version = "1.0.0" +author = "Backslash" +icon = "bs-download" +license = "MIT" + +# getfattr reads where a file was downloaded from (user.xdg.origin.url, set by +# browsers). mv and mkdir move and rename without overwriting, gio moves to the +# trash, gdbus shows a file in the file manager, wl-copy and xclip copy a file +# as a file. +[permissions] +run = ["getfattr", "gio", "mv", "mkdir", "gdbus", "wl-copy", "xclip"] +# The download folder and the other XDG folders (to move files into them) can be +# anywhere in the home folder, under localized names (Téléchargements, Descargas…). +read = ["~"] +env = ["HOME", "WAYLAND_DISPLAY"] + +[[commands]] +name = "downloads" +title = "Downloads" +description = "Your recent downloads by day, with picture previews" +icon = "bs-download" +keywords = [ + "downloads", + "download", + "téléchargements", + "téléchargement", + "fichiers récents", + "recent files", + "files", + "fichiers", + "installer", + "archive", +] diff --git a/extensions/downloads/commands/downloads.ts b/extensions/downloads/commands/downloads.ts new file mode 100644 index 0000000..a32fecd --- /dev/null +++ b/extensions/downloads/commands/downloads.ts @@ -0,0 +1,398 @@ +import { + type Action, + action, + clipboard, + type Color, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + openExternal, + pop, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + checkName, + extTag, + fileUri, + formatBytes, + freeName, + hostOf, + type Kind, + KIND_TITLES, + kindOf, + matchesFilter, + parseGetfattr, + parseUserDirs, + previewable, + section, + tilde, + when, + XDG_TITLES, + type XdgDir, +} from "../lib.ts"; + +const env = (k: string) => { + try { + return Deno.env.get(k) || undefined; + } catch { + return undefined; + } +}; +const HOME = env("HOME") ?? ""; +const t = (p: string) => tilde(p, HOME); + +interface Download { + name: string; + path: string; + size: number; + modified: Date; + kind: Kind; + isDir: boolean; + url?: string; + referrer?: string; +} + +const ICONS: Record = { + image: { icon: Icons.image, tint: "purple" }, + video: { icon: Icons.film, tint: "pink" }, + audio: { icon: Icons.music, tint: "pink" }, + document: { icon: Icons.file, tint: "orange" }, + archive: { icon: Icons.archive, tint: "teal" }, + installer: { icon: Icons.package, tint: "green" }, + code: { icon: Icons.code, tint: "blue" }, + folder: { icon: Icons.folder, tint: "blue" }, + partial: { icon: Icons.download, tint: "yellow" }, + other: { icon: Icons.file, tint: "gray" }, +}; + +/** Things that changed the folder from here: the list reloads at once. */ +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +/** The XDG folders; the usual English names where user-dirs.dirs says nothing and they exist. */ +async function folders(): Promise>> { + const dirs = parseUserDirs(await Deno.readTextFile(`${HOME}/.config/user-dirs.dirs`).catch(() => ""), HOME); + for (const key of Object.keys(XDG_TITLES) as XdgDir[]) { + const fallback = `${HOME}/${XDG_TITLES[key]}`; + if (!dirs[key] && (key === "DOWNLOAD" || await exists(fallback))) dirs[key] = fallback; + } + return dirs; +} + +let getfattrMissing = false; + +/** Where files came from, as browsers record it (`user.xdg.origin.url`), in one getfattr call. */ +async function origins(paths: string[]): Promise>> { + if (!paths.length || getfattrMissing) return new Map(); + try { + const out = await exec("getfattr", ["--absolute-names", "-d", "-m", "^user\\.xdg\\.", ...paths], { + signal: AbortSignal.timeout(5000), + }).catch((e) => { + if (e instanceof Deno.errors.NotFound) throw e; + // Non-zero when one file has no attribute support; the rest is still printed. + return String(e?.message ?? ""); + }); + return parseGetfattr(out); + } catch (e) { + if (e instanceof Deno.errors.NotFound) getfattrMissing = true; + return new Map(); + } +} + +async function load(dir: string): Promise { + const out: Download[] = []; + try { + for await (const e of Deno.readDir(dir)) { + if (e.name.startsWith(".")) continue; + const path = `${dir}/${e.name}`; + const st = await Deno.stat(path).catch(() => undefined); + if (!st) continue; + out.push({ + name: e.name, + path, + size: st.size, + modified: st.mtime ?? new Date(0), + kind: kindOf(e.name, st.isDirectory), + isDir: st.isDirectory, + }); + } + } catch (e) { + if (e instanceof Deno.errors.NotFound) return []; + throw e; + } + out.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + const recent = out.slice(0, 250); + const attrs = await origins(recent.filter((d) => !d.isDir).slice(0, 120).map((d) => d.path)); + for (const d of recent) { + const a = attrs.get(d.path); + d.url = a?.["user.xdg.origin.url"]; + d.referrer = a?.["user.xdg.referrer.url"]; + } + return recent; +} + +async function pipe(program: string, args: string[], input: string): Promise { + try { + const child = new Deno.Command(program, { args, stdin: "piped", stdout: "null", stderr: "null" }).spawn(); + const w = child.stdin.getWriter(); + await w.write(new TextEncoder().encode(input)); + await w.close(); + // wl-copy and xclip stay in the background to serve the clipboard. + const done = await Promise.race([ + child.status.then((s) => s.success), + new Promise((r) => setTimeout(r, 400, true)), + ]); + return Boolean(done); + } catch { + return false; + } +} + +/** Puts the file on the clipboard as a file (text/uri-list), so a file manager or chat app pastes the file. */ +async function copyFile(path: string): Promise { + const uri = `${fileUri(path)}\r\n`; + const wayland = !!env("WAYLAND_DISPLAY"); + const wl = () => pipe("wl-copy", ["--type", "text/uri-list"], uri); + const x = () => pipe("xclip", ["-selection", "clipboard", "-t", "text/uri-list"], uri); + return wayland ? (await wl()) || (await x()) : (await x()) || (await wl()); +} + +async function showInFolder(path: string): Promise { + try { + await exec("gdbus", [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + `['${fileUri(path).replace(/'/g, "%27")}']`, + "", + ]); + } catch { + await openExternal(path.split("/").slice(0, -1).join("/") || "/"); + } +} + +async function exists(p: string) { + try { + await Deno.lstat(p); + return true; + } catch { + return false; + } +} + +/** Moves without overwriting: mv -n, then checks it really moved. */ +async function move(from: string, to: string) { + if (await exists(to)) throw new Error(`${t(to)} already exists`); + await exec("mv", ["-n", "-T", from, to]); + if (await exists(from)) throw new Error(`Couldn't move ${t(from)}`); +} + +async function moveInto(d: Download, folder: string): Promise { + const names = new Set(); + try { + for await (const e of Deno.readDir(folder)) names.add(e.name); + } catch { + await exec("mkdir", ["-p", folder]); + } + const dest = `${folder}/${freeName(d.name, (n) => names.has(n))}`; + await move(d.path, dest); + return dest; +} + +function renameForm(d: Download) { + return form({ + title: `Rename ${d.name}`, + submitTitle: "Rename", + fields: [field.text("name", "Name", { value: d.name, required: true })], + async submit(values) { + const name = String(values.name ?? "").trim(); + const problem = checkName(name); + if (problem) throw new Error(problem); + if (name === d.name) return pop(); + const dest = `${d.path.split("/").slice(0, -1).join("/")}/${name}`; + await move(d.path, dest); + changed(); + await toast(`Renamed to ${name}`, { style: "success" }); + return pop(); + }, + }); +} + +function moveForm(d: Download) { + return form({ + title: `Move ${d.name}`, + submitTitle: "Move", + fields: [field.text("folder", "Folder", { placeholder: "~/Projects/invoices", required: true })], + async submit(values) { + const raw = String(values.folder ?? "").trim().replace(/\/+$/, ""); + if (!raw) throw new Error("Type a folder"); + const folder = raw.startsWith("~") ? `${HOME}${raw.slice(1)}` : raw.startsWith("/") ? raw : `${HOME}/${raw}`; + const dest = await moveInto(d, folder); + changed(); + await toast(`Moved to ${t(dest)}`, { style: "success" }); + return pop(); + }, + }); +} + +function actionsFor(d: Download, dirs: Partial>): Action[] { + const targets = (Object.keys(XDG_TITLES) as XdgDir[]).filter((k) => k !== "DOWNLOAD" && dirs[k]); + return [ + open(d.path, d.isDir ? "Open Folder" : "Open", { icon: Icons.external }), + action("Show in Folder", async () => { + await showInFolder(d.path); + }, { icon: Icons.folder, shortcut: "f" }), + action("Copy File", async () => { + if (await copyFile(d.path)) { + await toast(`${d.name} copied: paste it in a file manager or a chat`, { style: "success" }); + } else { + await clipboard.write(fileUri(d.path)); + await toast("Copied as a link (install wl-clipboard or xclip to copy the file itself)"); + } + }, { icon: Icons.copy, shortcut: "c", section: "Copy" }), + copy(d.path, "Copy Path", { section: "Copy", shortcut: "c" }), + copy(d.name, "Copy Name", { section: "Copy" }), + ...(d.url ? [copy(d.url, "Copy Download Link", { section: "Copy", icon: Icons.link })] : []), + ...(d.referrer ? [open(d.referrer, "Open Source Page", { icon: Icons.globe, section: "Copy" })] : []), + submenu( + "Move to…", + targets.map((k) => + action(XDG_TITLES[k], async () => { + const dest = await moveInto(d, dirs[k]!); + changed(); + await toast(`Moved to ${t(dest)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.folder }) + ).concat([action("Other Folder…", () => moveForm(d), { icon: Icons.plus })]), + { icon: Icons.arrowRight, shortcut: "m", section: "Organize" }, + ), + action("Rename…", () => renameForm(d), { icon: Icons.text, shortcut: "F2", section: "Organize" }), + action("Move to Trash", async () => { + try { + await exec("gio", ["trash", d.path]); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("gio (GLib) is needed to move files to the trash"); + throw e; + } + changed(); + await toast(`${d.name} moved to the trash`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Move ${d.name} to the trash?`, + }), + ]; +} + +function item(d: Download, dirs: Partial>): Item { + const { icon, tint } = ICONS[d.kind]; + const host = hostOf(d.url) ?? hostOf(d.referrer); + const ext = d.isDir ? undefined : extTag(d.name); + const metadata: Metadata[] = [ + meta.text("Downloaded", d.modified.toLocaleString("en-GB"), { icon: Icons.clock }), + meta.tags("Type", [ + { text: KIND_TITLES[d.kind], color: tint }, + ...(ext ? [{ text: ext }] : []), + ]), + ...(d.url ? [meta.link("From", hostOf(d.url) ?? d.url, d.url)] : []), + ...(d.referrer && d.referrer !== d.url ? [meta.link("Page", hostOf(d.referrer) ?? d.referrer, d.referrer)] : []), + ...(!d.url && getfattrMissing + ? [meta.text("Source", "Install getfattr (attr) to see it", { icon: Icons.info, color: "gray" })] + : []), + meta.separator(), + meta.text("Path", t(d.path), { icon: Icons.folder }), + ]; + return { + id: d.path, + title: d.name, + subtitle: host, + icon: { source: icon, tint }, + section: section(d.modified), + keywords: [d.kind, KIND_TITLES[d.kind], host ?? "", ext ?? ""], + accessories: [ + ...(d.kind === "partial" ? [{ tag: "Downloading", color: "yellow" as const }] : []), + ...(d.isDir ? [] : [{ text: formatBytes(d.size) }]), + { text: when(d.modified), color: "gray" as const }, + ], + detail: { + image: previewable(d.name) ? d.path : undefined, + stats: d.isDir ? [{ label: "Kind", value: "Folder" }] : [ + { label: "Size", value: formatBytes(d.size) }, + { label: "Kind", value: ext ?? KIND_TITLES[d.kind], color: tint }, + ], + metadata, + }, + actions: actionsFor(d, dirs), + }; +} + +const sleepUntil = (ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + +let status = ""; + +export default async () => { + const dirs = await folders(); + const dir = dirs.DOWNLOAD ?? `${HOME}/Downloads`; + return list({ + placeholder: "Filter recent downloads…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "images", title: "Images" }, + { value: "documents", title: "Documents" }, + { value: "archives", title: "Archives" }, + { value: "installers", title: "Installers" }, + ], + }, + empty: { title: "Nothing downloaded here", description: "Files you download show up here", icon: Icons.download }, + status: () => status, + // New downloads appear by themselves; changes made here show at once. + async *items({ filter, signal }) { + let last = ""; + while (!signal.aborted) { + const all = await load(dir); + const key = all.map((d) => `${d.name}:${d.size}:${d.modified.getTime()}`).join("|"); + if (key !== last) { + last = key; + const total = all.reduce((s, d) => s + (d.isDir ? 0 : d.size), 0); + const today = all.filter((d) => section(d.modified) === "Today").length; + status = `${all.length} item${all.length === 1 ? "" : "s"} · ${formatBytes(total)} · ${t(dir)}`; + updateCommandMetadata({ subtitle: today ? `${today} today` : all[0] ? `Latest: ${all[0].name}` : null }) + .catch(() => {}); + yield all.filter((d) => matchesFilter(d.kind, filter ?? "all")).map((d) => item(d, dirs)); + } + await sleepUntil(3000, signal); + } + }, + actions: [open(dir, "Open Downloads Folder", { icon: Icons.folder, shortcut: "o" })], + }); +}; diff --git a/extensions/downloads/lib.ts b/extensions/downloads/lib.ts new file mode 100644 index 0000000..2b07e67 --- /dev/null +++ b/extensions/downloads/lib.ts @@ -0,0 +1,235 @@ +// XDG folders, file kinds and dates for the Downloads list. Pure, tested in lib_test.ts. + +export type XdgDir = + | "DESKTOP" + | "DOWNLOAD" + | "TEMPLATES" + | "PUBLICSHARE" + | "DOCUMENTS" + | "MUSIC" + | "PICTURES" + | "VIDEOS"; + +export const XDG_TITLES: Record = { + DESKTOP: "Desktop", + DOWNLOAD: "Downloads", + TEMPLATES: "Templates", + PUBLICSHARE: "Public", + DOCUMENTS: "Documents", + MUSIC: "Music", + PICTURES: "Pictures", + VIDEOS: "Videos", +}; + +/** `~/.config/user-dirs.dirs` → absolute folders. `$HOME/` alone means the folder is disabled. */ +export function parseUserDirs(text: string, home: string): Partial> { + const out: Partial> = {}; + for (const m of text.matchAll(/^\s*XDG_([A-Z]+)_DIR="([^"]*)"/gm)) { + const key = m[1] as XdgDir; + if (!(key in XDG_TITLES)) continue; + const p = m[2].replace(/^\$HOME/, home).replace(/\/+$/, ""); + if (p && p !== home) out[key] = p; + } + return out; +} + +export type Kind = + | "image" + | "video" + | "audio" + | "document" + | "archive" + | "installer" + | "code" + | "folder" + | "partial" + | "other"; + +const KINDS: [Kind, RegExp][] = [ + ["partial", /\.(crdownload|part|download|partial)$/i], + ["installer", /\.(deb|rpm|appimage|flatpakref|flatpak|snap|run|pkg\.tar\.zst|exe|msi|dmg)$/i], + ["image", /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|tiff?|ico)$/i], + ["video", /\.(mp4|mkv|webm|mov|avi|m4v)$/i], + ["audio", /\.(mp3|flac|ogg|opus|wav|m4a|aac)$/i], + ["archive", /\.(zip|tar|gz|tgz|xz|zst|bz2|7z|rar|iso)$/i], + ["document", /\.(pdf|docx?|odt|xlsx?|ods|pptx?|odp|rtf|epub|txt|md|csv)$/i], + ["code", /\.(json|toml|ya?ml|sh|py|js|ts|html|css|xml|sql)$/i], +]; + +export function kindOf(name: string, isDir = false): Kind { + if (isDir) return "folder"; + for (const [k, re] of KINDS) if (re.test(name)) return k; + return "other"; +} + +export const KIND_TITLES: Record = { + image: "Picture", + video: "Video", + audio: "Audio", + document: "Document", + archive: "Archive", + installer: "Installer", + code: "Code", + folder: "Folder", + partial: "Downloading", + other: "File", +}; + +/** Which filter chip shows a kind. */ +export function matchesFilter(kind: Kind, filter: string): boolean { + switch (filter) { + case "images": + return kind === "image"; + case "documents": + return kind === "document"; + case "archives": + return kind === "archive"; + case "installers": + return kind === "installer"; + default: + return true; + } +} + +/** Pictures the launcher can show in the side panel. */ +export const previewable = (name: string) => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name); + +/** The extension in capitals, for the tag: "PDF", "TAR.GZ". */ +export function extTag(name: string): string | undefined { + const m = name.match(/\.(tar\.(?:gz|xz|zst|bz2)|[a-z0-9]{1,8})$/i); + return m ? m[1].toUpperCase() : undefined; +} + +// --------------------------------------------------------------------------- +// Dates + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + +export function section(date: Date, now = new Date()): "Today" | "Yesterday" | "This Week" | "Older" { + const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return "This Week"; + return "Older"; +} + +/** "14:03", "Yesterday 09:12", "Mon 26 Oct", "3 Mar 2025". */ +export function when(date: Date, now = new Date()): string { + const time = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + const s = section(date, now); + if (s === "Today") return time; + if (s === "Yesterday") return `Yesterday ${time}`; + if (s === "This Week") return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }); + return date.toLocaleDateString( + "en-GB", + date.getFullYear() === now.getFullYear() + ? { day: "numeric", month: "short" } + : { day: "numeric", month: "short", year: "numeric" }, + ); +} + +// --------------------------------------------------------------------------- +// Files + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** `file:///home/ada/My%20File.pdf` */ +export const fileUri = (path: string) => `file://${path.split("/").map(encodeURIComponent).join("/")}`; + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); + +/** A new file name, or why it can't be one. */ +export function checkName(name: string): string | undefined { + const n = name.trim(); + if (!n) return "Type a name"; + if (n.includes("/")) return "A name can't contain “/”"; + if (n === "." || n === "..") return "Not a valid name"; + if (new TextEncoder().encode(n).length > 255) return "That name is too long"; + return undefined; +} + +/** The host of the page a file came from, for the subtitle. */ +export function hostOf(url?: string): string | undefined { + if (!url) return undefined; + try { + const u = new URL(url); + return u.protocol.startsWith("http") ? u.hostname.replace(/^www\./, "") : undefined; + } catch { + return undefined; + } +} + +/** A free name in a folder: `report.pdf` → `report (2).pdf`. */ +export function freeName(name: string, taken: (n: string) => boolean): string { + if (!taken(name)) return name; + const m = name.match(/^(.*?)(\.(?:tar\.(?:gz|xz|zst|bz2)|[^.]+))?$/)!; + const [stem, ext] = [m[1] || name, m[2] ?? ""]; + for (let i = 2; i < 1000; i++) { + const n = `${stem} (${i})${ext}`; + if (!taken(n)) return n; + } + return `${stem} (${Date.now()})${ext}`; +} + +/** + * `getfattr --absolute-names -d -m '^user\.xdg\.' `: attributes per + * file. Text values are quoted, with octal escapes (`\012`); `0x…`/`0s…` + * values are hex or base64. + */ +export function parseGetfattr(text: string): Map> { + const out = new Map>(); + let current: Record | undefined; + for (const line of text.split("\n")) { + const file = line.match(/^# file: (.*)$/); + if (file) { + current = {}; + out.set(unescapeOctal(file[1]), current); + continue; + } + const kv = line.match(/^([\w.]+)=(.*)$/); + if (!kv || !current) continue; + current[kv[1]] = decodeValue(kv[2]); + } + return out; +} + +function unescapeOctal(s: string): string { + const bytes: number[] = []; + for (let i = 0; i < s.length; i++) { + const m = s.slice(i).match(/^\\([0-7]{3})/); + if (m) { + bytes.push(parseInt(m[1], 8)); + i += 3; + } else if (s[i] === "\\" && s[i + 1] === "\\") { + bytes.push(0x5c); + i++; + } else bytes.push(...new TextEncoder().encode(s[i])); + } + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +function decodeValue(v: string): string { + if (v.startsWith('"') && v.endsWith('"')) return unescapeOctal(v.slice(1, -1).replace(/\\"/g, '"')); + if (v.startsWith("0x")) { + const hex = v.slice(2).match(/../g) ?? []; + return new TextDecoder().decode(new Uint8Array(hex.map((h) => parseInt(h, 16)))); + } + if (v.startsWith("0s")) { + try { + return new TextDecoder().decode(Uint8Array.from(atob(v.slice(2)), (c) => c.charCodeAt(0))); + } catch { + return ""; + } + } + return v; +} diff --git a/extensions/downloads/lib_test.ts b/extensions/downloads/lib_test.ts new file mode 100644 index 0000000..7a42e32 --- /dev/null +++ b/extensions/downloads/lib_test.ts @@ -0,0 +1,99 @@ +import { assertEquals } from "@std/assert"; +import { + checkName, + extTag, + fileUri, + freeName, + hostOf, + kindOf, + matchesFilter, + parseGetfattr, + parseUserDirs, + previewable, + section, + when, +} from "./lib.ts"; + +Deno.test("user-dirs.dirs", () => { + const dirs = parseUserDirs( + `# This file is written by xdg-user-dirs-update +XDG_DESKTOP_DIR="$HOME/Bureau" +XDG_DOWNLOAD_DIR="$HOME/Téléchargements" +XDG_TEMPLATES_DIR="$HOME/" +XDG_DOCUMENTS_DIR="/data/Documents" +XDG_PICTURES_DIR="$HOME/Images/" +`, + "/home/ada", + ); + assertEquals(dirs, { + DESKTOP: "/home/ada/Bureau", + DOWNLOAD: "/home/ada/Téléchargements", + DOCUMENTS: "/data/Documents", + PICTURES: "/home/ada/Images", + }); +}); + +Deno.test("kinds and filters", () => { + assertEquals(kindOf("photo.JPEG"), "image"); + assertEquals(kindOf("Invoice-2026.pdf"), "document"); + assertEquals(kindOf("backup.tar.zst"), "archive"); + assertEquals(kindOf("code_1.95_amd64.deb"), "installer"); + assertEquals(kindOf("Obsidian-1.7.4.AppImage"), "installer"); + assertEquals(kindOf("yay-12.4.2-1-x86_64.pkg.tar.zst"), "installer"); + assertEquals(kindOf("movie.mkv.crdownload"), "partial"); + assertEquals(kindOf("thing"), "other"); + assertEquals(kindOf("photos", true), "folder"); + assertEquals(matchesFilter("installer", "installers"), true); + assertEquals(matchesFilter("image", "documents"), false); + assertEquals(matchesFilter("other", "all"), true); + assertEquals(previewable("a.png"), true); + assertEquals(extTag("backup.tar.gz"), "TAR.GZ"); + assertEquals(extTag("report.PDF"), "PDF"); + assertEquals(extTag("README"), undefined); +}); + +Deno.test("sections and dates", () => { + const now = new Date(2026, 9, 28, 15, 0); // Wednesday + assertEquals(section(new Date(2026, 9, 28, 1), now), "Today"); + assertEquals(section(new Date(2026, 9, 27, 23), now), "Yesterday"); + assertEquals(section(new Date(2026, 9, 23), now), "This Week"); + assertEquals(section(new Date(2026, 9, 21), now), "Older"); + assertEquals(when(new Date(2026, 9, 28, 9, 5), now), "09:05"); + assertEquals(when(new Date(2026, 9, 27, 18, 30), now), "Yesterday 18:30"); + assertEquals(when(new Date(2026, 9, 26, 8), now), "Mon 26 Oct"); + assertEquals(when(new Date(2025, 2, 3), now), "3 Mar 2025"); +}); + +Deno.test("getfattr output", () => { + const out = `# file: /home/ada/Downloads/report final.pdf +user.xdg.origin.url="https://example.com/files/report%20final.pdf" +user.xdg.referrer.url="https://example.com/reports?id=3" + +# file: /home/ada/Downloads/caf\\303\\251.png +user.xdg.origin.url="https://cdn.example.org/caf\\303\\251.png" + +# file: /home/ada/Downloads/bin.dat +user.xdg.origin.url=0x68747470733a2f2f612e6f7267 +`; + const attrs = parseGetfattr(out); + assertEquals(attrs.get("/home/ada/Downloads/report final.pdf"), { + "user.xdg.origin.url": "https://example.com/files/report%20final.pdf", + "user.xdg.referrer.url": "https://example.com/reports?id=3", + }); + assertEquals(attrs.get("/home/ada/Downloads/café.png")?.["user.xdg.origin.url"], "https://cdn.example.org/café.png"); + assertEquals(attrs.get("/home/ada/Downloads/bin.dat")?.["user.xdg.origin.url"], "https://a.org"); +}); + +Deno.test("names, links and uris", () => { + assertEquals(checkName(""), "Type a name"); + assertEquals(checkName("a/b"), "A name can't contain “/”"); + assertEquals(checkName("ok.txt"), undefined); + assertEquals(hostOf("https://www.github.com/x"), "github.com"); + assertEquals(hostOf("data:abc"), undefined); + assertEquals(hostOf(undefined), undefined); + assertEquals(fileUri("/home/ada/My File#1.pdf"), "file:///home/ada/My%20File%231.pdf"); + const taken = new Set(["report.pdf", "report (2).pdf", "a.tar.gz"]); + assertEquals(freeName("report.pdf", (n) => taken.has(n)), "report (3).pdf"); + assertEquals(freeName("a.tar.gz", (n) => taken.has(n)), "a (2).tar.gz"); + assertEquals(freeName("new.txt", (n) => taken.has(n)), "new.txt"); +}); diff --git a/extensions/flatpak/backslash.toml b/extensions/flatpak/backslash.toml new file mode 100644 index 0000000..cd50734 --- /dev/null +++ b/extensions/flatpak/backslash.toml @@ -0,0 +1,45 @@ +[extension] +id = "flatpak" +title = "Flatpak Apps" +description = "Browse installed Flatpak apps and runtimes, see their size and permissions, run or uninstall them" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +# flatpak lists, runs, uninstalls and resets overrides (system uninstalls ask +# through polkit). The exported icon folders give each app its own icon, and +# ~/.var/app tells whether an app keeps data. +[permissions] +run = ["flatpak"] +read = [ + "/var/lib/flatpak/exports/share/icons/hicolor", + "~/.local/share/flatpak/exports/share/icons/hicolor", + "~/.var/app", + "/etc/os-release", +] +env = ["HOME"] + +[[commands]] +name = "flatpak" +title = "Flatpak Apps" +description = "Installed apps and runtimes with their size, origin and permissions" +icon = "bs-package" +keywords = [ + "flatpak", + "flathub", + "app", + "apps", + "applications", + "logiciels", + "runtime", + "uninstall", + "désinstaller", + "supprimer", + "permissions", + "sandbox", + "bac à sable", + "flatseal", + "unused", + "inutilisé", +] diff --git a/extensions/flatpak/commands/flatpak.ts b/extensions/flatpak/commands/flatpak.ts new file mode 100644 index 0000000..7d92b12 --- /dev/null +++ b/extensions/flatpak/commands/flatpak.ts @@ -0,0 +1,340 @@ +import { + type Action, + action, + close, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + COLUMNS, + flathubUrl, + formatBytes, + type Grant, + installMarkdown, + type Level, + osFamily, + parseList, + parsePermissions, + type Ref, + sandboxScore, + splitRuntime, + summarize, + uninstallArgs, + unusedRuntimes, +} from "../lib.ts"; + +const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); + +const LEVEL_COLOR: Record = { high: "red", medium: "orange", low: "gray" }; +const LEVEL_TEXT: Record = { + high: "Wide access", + medium: "Some access", + low: "Well sandboxed", +}; + +async function flatpak(args: string[], signal?: AbortSignal): Promise { + try { + return await exec("flatpak", args, { signal }); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new NotInstalled(); + throw e; + } +} + +class NotInstalled extends Error {} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} + +/** The app's own icon from the exported icon theme, if any. */ +async function iconOf(r: Ref): Promise { + const roots = r.installation === "user" + ? [`${HOME}/.local/share/flatpak/exports/share/icons/hicolor`] + : ["/var/lib/flatpak/exports/share/icons/hicolor"]; + for (const root of roots) { + for (const p of ["128x128/apps", "scalable/apps", "256x256/apps", "64x64/apps", "512x512/apps"]) { + for (const ext of p.startsWith("scalable") ? ["svg"] : ["png"]) { + const file = `${root}/${p}/${r.id}.${ext}`; + if (await exists(file)) return file; + } + } + } + return undefined; +} + +/** Runs `fn` over the items, a few at a time. */ +async function pool(items: T[], n: number, fn: (t: T) => Promise) { + const queue = [...items]; + await Promise.all(Array.from({ length: n }, async () => { + for (let t = queue.shift(); t !== undefined; t = queue.shift()) await fn(t); + })); +} + +async function uninstall(r: Ref, deleteData = false) { + await toast(`Uninstalling ${r.name}…`); + await flatpak(uninstallArgs(r, deleteData)); + await toast(`${r.name} uninstalled`, { style: "success" }); + return refresh(); +} + +const removeUnused = action("Remove Unused Runtimes", async () => { + await toast("Removing unused runtimes…"); + const out = await flatpak(["uninstall", "--unused", "--noninteractive", "-y"]); + const nothing = /Nothing unused/i.test(out); + await toast(nothing ? "Nothing unused to remove" : "Unused runtimes removed", { style: "success" }); + return refresh(); +}, { + icon: Icons.trash, + destructive: true, + confirm: "Remove every runtime no installed app needs?", + section: "Danger", + shortcut: "u", +}); + +function grantTags(grants: Grant[]) { + return grants.slice(0, 10).map((g) => ({ text: g.text, color: LEVEL_COLOR[g.level] })); +} + +function appItem(r: Ref, icon: string | undefined, grants: Grant[] | undefined, hasData: boolean): Item { + const url = flathubUrl(r); + const level = grants ? sandboxScore(grants) : undefined; + const runtime = splitRuntime(r.runtime); + const dataDir = `${HOME}/.var/app/${r.id}`; + const metadata: Metadata[] = [ + meta.text("App ID", r.id, { icon: Icons.hash }), + ...(r.version ? [meta.text("Version", r.version, { icon: Icons.tag })] : []), + meta.tags("Source", [ + { text: r.origin || "unknown", color: /flathub/i.test(r.origin) ? "blue" : "gray" }, + { text: r.branch, color: r.branch === "stable" ? "green" : "orange" }, + { text: r.installation === "user" ? "User" : r.installation === "system" ? "System" : r.installation }, + ]), + meta.text("Runtime", `${runtime.id} ${runtime.branch ?? ""}`.trim(), { icon: Icons.layers }), + meta.text("Size", formatBytes(r.size), { icon: Icons.drive }), + ...(hasData ? [meta.text("Data", `~/.var/app/${r.id}`, { icon: Icons.folder })] : []), + meta.separator(), + ...(grants + ? [ + meta.text("Sandbox", LEVEL_TEXT[level!], { + icon: Icons.shield, + color: LEVEL_COLOR[level!] === "gray" ? "green" : LEVEL_COLOR[level!], + }), + ...(grants.length ? [meta.tags("Permissions", grantTags(grants))] : [meta.text("Permissions", "None")]), + ] + : [meta.text("Permissions", "Reading…", { icon: Icons.shield })]), + ]; + const actions: Action[] = [ + action(`Run ${r.name}`, () => { + try { + new Deno.Command("flatpak", { args: ["run", r.id], stdin: "null", stdout: "null", stderr: "null" }).spawn() + .unref(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("flatpak was not found"); + throw e; + } + return close(); + }, { icon: Icons.play }), + ...(url ? [open(url, "Open in Flathub", { icon: Icons.globe, shortcut: "o" })] : []), + ...(hasData ? [open(dataDir, "Show Data Folder", { icon: Icons.folder, shortcut: "f" })] : []), + copy(r.id, "Copy App ID", { section: "Copy", shortcut: "c" }), + copy(`flatpak run ${r.id}`, "Copy Run Command", { section: "Copy" }), + ...(url ? [copy(url, "Copy Flathub Link", { section: "Copy" })] : []), + action("Reset Permissions", async () => { + await flatpak(["override", "--user", "--reset", r.id]); + await toast(`Permissions of ${r.name} reset to the defaults`, { style: "success" }); + return refresh(); + }, { + icon: Icons.shield, + section: "Danger", + confirm: `Forget every permission you changed for ${r.name} (Flatseal, flatpak override --user)?`, + }), + action("Uninstall", () => uninstall(r), { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "Delete", + confirm: `Uninstall ${r.name}? Its data in ~/.var/app is kept.`, + }), + action("Uninstall and Delete Data", () => uninstall(r, true), { + icon: Icons.trash, + destructive: true, + section: "Danger", + confirm: `Uninstall ${r.name} and delete its data and settings?`, + }), + removeUnused, + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ]; + return { + id: r.ref, + title: r.name, + subtitle: r.description || r.id, + icon: icon ?? { source: Icons.package, tint: "blue" }, + section: "Apps", + keywords: [r.id, r.origin, r.branch, "app", "application", "appli"], + accessories: [ + ...(level === "high" ? [{ icon: { source: Icons.shield, tint: "red" as const }, tooltip: "Wide access" }] : []), + ...(r.branch !== "stable" ? [{ tag: r.branch, color: "orange" as const }] : []), + ...(r.installation === "user" ? [{ tag: "User", color: "gray" as const }] : []), + { text: formatBytes(r.size), tooltip: "Installed size" }, + ], + detail: { + image: icon, + stats: [ + { label: "Size", value: formatBytes(r.size) }, + ...(r.version ? [{ label: "Version", value: r.version }] : []), + ...(level ? [{ label: "Sandbox", value: LEVEL_TEXT[level], color: LEVEL_COLOR[level] }] : []), + ], + metadata, + }, + actions, + }; +} + +function runtimeItem(r: Ref, unused: boolean, usedBy: Ref[]): Item { + return { + id: r.ref, + title: r.name === r.id ? r.id : r.name, + subtitle: `${r.id} · ${r.branch}`, + icon: { source: Icons.layers, tint: unused ? "orange" : "gray" }, + section: unused ? "Unused Runtimes" : "Runtimes", + keywords: [r.id, r.branch, r.origin, "runtime", "platform", unused ? "unused inutilisé" : ""], + accessories: [ + ...(unused ? [{ tag: "Unused", color: "orange" as const }] : []), + { text: formatBytes(r.size), tooltip: "Installed size" }, + ], + detail: { + stats: [ + { label: "Size", value: formatBytes(r.size) }, + { label: "Used by", value: usedBy.length ? `${usedBy.length} app${usedBy.length === 1 ? "" : "s"}` : "—" }, + ], + metadata: [ + meta.text("ID", r.id, { icon: Icons.hash }), + meta.text("Branch", r.branch, { icon: Icons.branch }), + meta.text("Ref", r.ref, { icon: Icons.code }), + meta.tags("Source", [ + { text: r.origin || "unknown", color: /flathub/i.test(r.origin) ? "blue" : "gray" }, + { text: r.installation === "user" ? "User" : r.installation === "system" ? "System" : r.installation }, + ]), + meta.text("State", unused ? "Probably unused" : "In use", { + icon: unused ? Icons.alert : Icons.check, + color: unused ? "orange" : "green", + }), + ...(usedBy.length ? [meta.tags("Apps", usedBy.slice(0, 8).map((a) => a.name))] : []), + ], + }, + actions: [ + ...(unused ? [removeUnused] : []), + copy(r.ref, "Copy Ref", { section: "Copy", shortcut: "c" }), + action("Uninstall", () => uninstall(r), { + icon: Icons.trash, + destructive: true, + section: "Danger", + confirm: usedBy.length + ? `${usedBy.length} app${usedBy.length === 1 ? " needs" : "s need"} ${r.id}. Uninstall it anyway?` + : `Uninstall ${r.id} ${r.branch}?`, + }), + ...(unused ? [] : [removeUnused]), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Flatpak apps and runtimes…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "apps", title: "Apps" }, + { value: "runtimes", title: "Runtimes" }, + { value: "unused", title: "Unused" }, + ], + }, + empty: { title: "Nothing here", description: "No Flatpak matches this view", icon: Icons.package }, + status: () => summary, + actions: [removeUnused, open("https://flathub.org", "Browse Flathub", { icon: Icons.globe })], + async *items({ filter, signal }) { + let apps: Ref[]; + let runtimes: Ref[]; + try { + [apps, runtimes] = await Promise.all([ + flatpak(["list", "--app", `--columns=${COLUMNS}`], signal).then((t) => parseList(t, "app")), + flatpak(["list", "--runtime", `--columns=${COLUMNS}`], signal).then((t) => parseList(t, "runtime")), + ]); + } catch (e) { + if (!(e instanceof NotInstalled)) throw e; + summary = "Flatpak isn't installed"; + const fam = osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); + yield [{ + id: "install", + title: "Install Flatpak", + subtitle: "Then get apps from Flathub", + icon: { source: Icons.download, tint: "orange" }, + detail: { markdown: installMarkdown(fam) }, + actions: [ + open("https://flathub.org/setup", "Open the Flathub Setup Guide", { icon: Icons.globe }), + action("Check Again", () => refresh(), { icon: Icons.refresh }), + ], + }]; + return; + } + const unused = unusedRuntimes(apps, runtimes); + const total = [...apps, ...runtimes].reduce((s, r) => s + r.size, 0); + const unusedSize = runtimes.filter((r) => unused.has(r.ref)).reduce((s, r) => s + r.size, 0); + summary = `${apps.length} app${apps.length === 1 ? "" : "s"} · ${runtimes.length} runtimes · ${formatBytes(total)}${ + unused.size ? ` · ${unused.size} unused (${formatBytes(unusedSize)})` : "" + }`; + updateCommandMetadata({ subtitle: `${apps.length} apps · ${formatBytes(total)}` }).catch(() => {}); + + const f = filter ?? "all"; + const shownApps = f === "all" || f === "apps" ? [...apps].sort((a, b) => a.name.localeCompare(b.name)) : []; + const shownRuntimes = (f === "all" || f === "runtimes" || f === "unused" ? runtimes : []) + .filter((r) => f !== "unused" || unused.has(r.ref)) + .sort((a, b) => Number(unused.has(b.ref)) - Number(unused.has(a.ref)) || b.size - a.size); + const usedBy = (r: Ref) => + apps.filter((a) => splitRuntime(a.runtime).id === r.id && splitRuntime(a.runtime).branch === r.branch); + + const icons = new Map(); + const data = new Map(); + await Promise.all(shownApps.map(async (a) => { + icons.set(a.ref, await iconOf(a)); + data.set(a.ref, await exists(`${HOME}/.var/app/${a.id}`)); + })); + const grants = new Map(); + const render = () => [ + ...shownApps.map((a) => appItem(a, icons.get(a.ref), grants.get(a.ref), data.get(a.ref) ?? false)), + ...shownRuntimes.map((r) => runtimeItem(r, unused.has(r.ref), usedBy(r))), + ]; + yield render(); + if (!shownApps.length) return; + // Permissions take one call per app: show the list first, then fill them in. + await pool(shownApps, 6, async (a) => { + if (signal.aborted) return; + const text = await flatpak(["info", "--show-permissions", a.ref], signal).catch(() => ""); + grants.set(a.ref, summarize(parsePermissions(text))); + }); + if (!signal.aborted) yield render(); + }, +}); diff --git a/extensions/flatpak/lib.ts b/extensions/flatpak/lib.ts new file mode 100644 index 0000000..f5116a8 --- /dev/null +++ b/extensions/flatpak/lib.ts @@ -0,0 +1,239 @@ +// Parsing flatpak's output and summing up what apps may do. Pure, tested in lib_test.ts. + +export const COLUMNS = "application,name,description,version,branch,arch,origin,installation,ref,runtime,size"; + +export interface Ref { + id: string; + name: string; + description: string; + version: string; + branch: string; + arch: string; + origin: string; + /** "user", "system" or a custom installation's name. */ + installation: string; + ref: string; + /** For apps: `org.gnome.Platform/x86_64/47`. */ + runtime: string; + size: number; + kind: "app" | "runtime"; +} + +/** "258.8 MB", "1.2 GB", "980 bytes", "12,5 kB" (GLib's decimal units) → bytes. */ +export function parseSize(text: string): number { + const m = text.trim().replace(",", ".").match(/^([\d.]+)\s*(bytes?|[kKMGTP]i?B)?/); + if (!m) return 0; + const n = parseFloat(m[1]); + const unit = (m[2] ?? "B").toLowerCase(); + const pow = unit.startsWith("byte") || unit === "b" ? 0 : "kmgtp".indexOf(unit[0]) + 1; + const base = unit.includes("i") ? 1024 : 1000; + return Math.round(n * base ** pow); +} + +/** `flatpak list --app|--runtime --columns=…` (tab separated). */ +export function parseList(text: string, kind: Ref["kind"]): Ref[] { + return text.split("\n").filter((l) => l.trim()).map((line) => { + const [id, name, description, version, branch, arch, origin, installation, ref, runtime, size] = line.split("\t"); + return { + id: id ?? "", + name: name || id || "", + description: description ?? "", + version: version ?? "", + branch: branch ?? "", + arch: arch ?? "", + origin: origin ?? "", + installation: installation ?? "", + ref: ref ?? "", + runtime: runtime ?? "", + size: parseSize(size ?? ""), + kind, + }; + }).filter((r) => r.id); +} + +/** `org.gnome.Platform/x86_64/47` → id and branch. */ +export function splitRuntime(runtime: string): { id: string; arch?: string; branch?: string } { + const [id, arch, branch] = runtime.split("/"); + return { id, arch, branch }; +} + +/** + * Runtimes no installed app uses, directly or as an extension of a used + * runtime (`org.freedesktop.Platform.GL.default` goes with + * `org.freedesktop.Platform`, `org.mozilla.firefox.Locale` with Firefox). + * A good guess; `flatpak uninstall --unused` has the final say. + */ +export function unusedRuntimes(apps: Ref[], runtimes: Ref[]): Set { + const used = new Set(apps.map((a) => { + const r = splitRuntime(a.runtime); + return `${r.id}/${r.branch ?? ""}`; + })); + const bases = [...apps.map((a) => a.id), ...runtimes.filter((r) => used.has(`${r.id}/${r.branch}`)).map((r) => r.id)]; + const direct = (r: Ref) => used.has(`${r.id}/${r.branch}`); + const extension = (r: Ref) => bases.some((b) => r.id.startsWith(`${b}.`)); + // An extension of a runtime branch nothing uses goes with it (org.gnome.Platform.Locale of an old branch). + const orphan = (r: Ref) => + runtimes.some((base) => r.id.startsWith(`${base.id}.`) && base.branch === r.branch && !direct(base)); + const out = new Set(); + for (const r of runtimes) { + if (direct(r)) continue; + if (extension(r) && !orphan(r)) continue; + out.add(r.ref); + } + return out; +} + +// --------------------------------------------------------------------------- +// Permissions + +export interface Permissions { + shared: string[]; + sockets: string[]; + devices: string[]; + features: string[]; + filesystems: string[]; + talk: string[]; + own: string[]; + system: string[]; +} + +/** `flatpak info --show-permissions ` (a keyfile). */ +export function parsePermissions(text: string): Permissions { + const p: Permissions = { + shared: [], + sockets: [], + devices: [], + features: [], + filesystems: [], + talk: [], + own: [], + system: [], + }; + let group = ""; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const g = line.match(/^\[(.+)\]$/); + if (g) { + group = g[1]; + continue; + } + const kv = line.match(/^([^=]+)=(.*)$/); + if (!kv) continue; + const [key, value] = [kv[1].trim(), kv[2].trim()]; + const values = value.split(";").filter(Boolean); + if (group === "Context" && key in p) (p as unknown as Record)[key].push(...values); + if (group === "Session Bus Policy") (value === "own" ? p.own : p.talk).push(key); + if (group === "System Bus Policy") p.system.push(key); + } + return p; +} + +export type Level = "high" | "medium" | "low"; +export interface Grant { + text: string; + level: Level; +} + +const FS_NAMES: Record = { + host: "All files", + "host-os": "System files", + "host-etc": "System settings (/etc)", + home: "Home folder", + "xdg-download": "Downloads", + "xdg-documents": "Documents", + "xdg-pictures": "Pictures", + "xdg-music": "Music", + "xdg-videos": "Videos", + "xdg-desktop": "Desktop", + "xdg-config": "App settings", + "xdg-run/pipewire-0": "PipeWire", +}; + +/** The permissions worth knowing about, worst first. Negated ones (`!home`) are left out. */ +export function summarize(p: Permissions): Grant[] { + const out: Grant[] = []; + const add = (text: string, level: Level) => out.push({ text, level }); + for (const f of p.filesystems) { + if (f.startsWith("!")) continue; + const [path, mode] = f.split(":"); + const name = FS_NAMES[path] ?? path.replace(/^~\//, "~/"); + const level: Level = ["host", "host-os", "home", "host-etc"].includes(path) ? "high" : "medium"; + add(`${name}${mode === "ro" ? " (read-only)" : mode === "create" ? " (create)" : ""}`, level); + } + if (p.devices.includes("all")) add("All devices", "high"); + else if (p.devices.length) add(`Devices: ${p.devices.join(", ")}`, p.devices.includes("dri") ? "low" : "medium"); + if (p.sockets.includes("x11") && !p.sockets.includes("fallback-x11")) add("X11 (can see other windows)", "medium"); + if (p.sockets.includes("system-bus")) add("Whole system bus", "high"); + if (p.sockets.includes("session-bus")) add("Whole session bus", "high"); + if (p.sockets.includes("ssh-auth")) add("SSH agent", "medium"); + if (p.sockets.includes("pulseaudio")) add("Sound", "low"); + if (p.sockets.includes("cups")) add("Printing", "low"); + if (p.sockets.includes("pcsc")) add("Smart cards", "medium"); + if (p.shared.includes("network")) add("Network", "low"); + if (p.features.includes("devel")) add("Debugging (ptrace)", "medium"); + if (p.talk.includes("org.freedesktop.Flatpak")) add("Run commands outside the sandbox", "high"); + if (p.talk.some((n) => n === "org.freedesktop.secrets")) add("Passwords (Secret Service)", "medium"); + if (p.talk.includes("org.freedesktop.Notifications")) add("Notifications", "low"); + const rank: Record = { high: 0, medium: 1, low: 2 }; + return out.sort((a, b) => rank[a.level] - rank[b.level]); +} + +export const sandboxScore = (grants: Grant[]): Level => + grants.some((g) => g.level === "high") ? "high" : grants.some((g) => g.level === "medium") ? "medium" : "low"; + +// --------------------------------------------------------------------------- +// Formatting + +export function formatBytes(n: number): string { + if (n < 1000) return `${n} B`; + const units = ["kB", "MB", "GB", "TB"]; + let v = n / 1000; + let i = 0; + while (v >= 1000 && i < units.length - 1) { + v /= 1000; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** The Flathub page for apps that came from Flathub. */ +export const flathubUrl = (r: Ref) => /flathub/i.test(r.origin) ? `https://flathub.org/apps/${r.id}` : undefined; + +/** `flatpak uninstall` arguments for one ref in its own installation. */ +export function uninstallArgs(r: Ref, deleteData = false): string[] { + const where = r.installation === "user" ? ["--user"] : r.installation === "system" ? ["--system"] : [ + `--installation=${r.installation}`, + ]; + return ["uninstall", ...where, "--noninteractive", "-y", ...(deleteData ? ["--delete-data"] : []), r.ref]; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record = { + arch: ["Arch", "sudo pacman -S flatpak"], + debian: ["Debian, Ubuntu", "sudo apt install flatpak"], + fedora: ["Fedora", "sudo dnf install flatpak"], + suse: ["openSUSE", "sudo zypper install flatpak"], +}; + +export function installMarkdown(family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + return [ + "Flatpak isn't installed. Install it, then add Flathub:", + ...families.map((f) => `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]}\``), + "`flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo`", + ].join("\n\n"); +} diff --git a/extensions/flatpak/lib_test.ts b/extensions/flatpak/lib_test.ts new file mode 100644 index 0000000..233defd --- /dev/null +++ b/extensions/flatpak/lib_test.ts @@ -0,0 +1,133 @@ +import { assertEquals } from "@std/assert"; +import { + flathubUrl, + formatBytes, + parseList, + parsePermissions, + parseSize, + sandboxScore, + splitRuntime, + summarize, + uninstallArgs, + unusedRuntimes, +} from "./lib.ts"; + +// `flatpak list --app --columns=application,name,description,version,branch,arch,origin,installation,ref,runtime,size` (flatpak 1.14). +export const APPS = [ + "org.mozilla.firefox\tFirefox\tFast, Private & Safe Web Browser\t131.0.3\tstable\tx86_64\tflathub\tsystem\tapp/org.mozilla.firefox/x86_64/stable\torg.freedesktop.Platform/x86_64/24.08\t258.8 MB", + "com.github.tchx84.Flatseal\tFlatseal\tManage Flatpak permissions\t2.2.0\tstable\tx86_64\tflathub\tuser\tapp/com.github.tchx84.Flatseal/x86_64/stable\torg.gnome.Platform/x86_64/47\t1.9 MB", + "org.gnome.Builder\tBuilder\tAn IDE for GNOME\t47.0\tbeta\tx86_64\tgnome-nightly\tsystem\tapp/org.gnome.Builder/x86_64/beta\torg.gnome.Sdk/x86_64/47\t60,1 MB", +].join("\n"); + +export const RUNTIMES = [ + "org.freedesktop.Platform\tFreedesktop Platform\t\t24.08.5\t24.08\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform/x86_64/24.08\t\t607.5 MB", + "org.freedesktop.Platform.GL.default\tMesa\t\t24.2.4\t24.08\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform.GL.default/x86_64/24.08\t\t420.3 MB", + "org.freedesktop.Platform.openh264\topenh264\t\t2.4.1\t2.4.1\tx86_64\tflathub\tsystem\truntime/org.freedesktop.Platform.openh264/x86_64/2.4.1\t\t1.0 MB", + "org.gnome.Platform\tGNOME Application Platform version 47\t\t\t47\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform/x86_64/47\t\t1.1 GB", + "org.gnome.Platform\tGNOME Application Platform version 45\t\t\t45\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform/x86_64/45\t\t981.2 MB", + "org.gnome.Platform.Locale\tTranslations\t\t\t45\tx86_64\tflathub\tsystem\truntime/org.gnome.Platform.Locale/x86_64/45\t\t18.0 kB", + "org.gnome.Sdk\tGNOME Software Development Kit version 47\t\t\t47\tx86_64\tflathub\tsystem\truntime/org.gnome.Sdk/x86_64/47\t\t1.6 GB", + "org.mozilla.firefox.Locale\tTranslations\t\t\tstable\tx86_64\tflathub\tsystem\truntime/org.mozilla.firefox.Locale/x86_64/stable\t\t38.2 MB", + "org.kde.Platform\tKDE Application Platform\t\t\t5.15-23.08\tx86_64\tflathub\tuser\truntime/org.kde.Platform/x86_64/5.15-23.08\t\t351.0 MB", +].join("\n"); + +Deno.test("sizes as flatpak prints them", () => { + assertEquals(parseSize("258.8 MB"), 258_800_000); + assertEquals(parseSize("1.1 GB"), 1_100_000_000); + assertEquals(parseSize("60,1 MB"), 60_100_000); + assertEquals(parseSize("980 bytes"), 980); + assertEquals(parseSize("18.0 kB"), 18_000); + assertEquals(parseSize("2.0 GiB"), 2 * 1024 ** 3); + assertEquals(parseSize(""), 0); + assertEquals(formatBytes(258_800_000), "259 MB"); + assertEquals(formatBytes(1_900_000), "1.9 MB"); +}); + +Deno.test("list columns", () => { + const apps = parseList(APPS, "app"); + assertEquals(apps.length, 3); + assertEquals(apps[1], { + id: "com.github.tchx84.Flatseal", + name: "Flatseal", + description: "Manage Flatpak permissions", + version: "2.2.0", + branch: "stable", + arch: "x86_64", + origin: "flathub", + installation: "user", + ref: "app/com.github.tchx84.Flatseal/x86_64/stable", + runtime: "org.gnome.Platform/x86_64/47", + size: 1_900_000, + kind: "app", + }); + assertEquals(splitRuntime(apps[0].runtime), { id: "org.freedesktop.Platform", arch: "x86_64", branch: "24.08" }); + assertEquals(parseList("\n", "app"), []); +}); + +Deno.test("unused runtimes", () => { + const unused = unusedRuntimes(parseList(APPS, "app"), parseList(RUNTIMES, "runtime")); + assertEquals([...unused].sort(), [ + "runtime/org.gnome.Platform.Locale/x86_64/45", + "runtime/org.gnome.Platform/x86_64/45", + "runtime/org.kde.Platform/x86_64/5.15-23.08", + ]); +}); + +const FIREFOX_PERMISSIONS = `[Context] +shared=network;ipc; +sockets=x11;wayland;pulseaudio;pcsc;cups; +devices=all; +filesystems=xdg-download;/run/.heim_org.h5l.kcm-socket;xdg-run/speech-dispatcher:ro;!home; + +[Session Bus Policy] +org.freedesktop.FileManager1=talk +org.freedesktop.Notifications=talk +org.mpris.MediaPlayer2.firefox.*=own +org.freedesktop.ScreenSaver=talk + +[System Bus Policy] +org.freedesktop.NetworkManager=talk + +[Environment] +GTK_PATH=/app/lib/gtkmodules +`; + +Deno.test("permissions summary, worst first", () => { + const p = parsePermissions(FIREFOX_PERMISSIONS); + assertEquals(p.shared, ["network", "ipc"]); + assertEquals(p.own, ["org.mpris.MediaPlayer2.firefox.*"]); + assertEquals(p.system, ["org.freedesktop.NetworkManager"]); + const grants = summarize(p); + assertEquals(grants.map((g) => g.text), [ + "All devices", + "Downloads", + "/run/.heim_org.h5l.kcm-socket", + "xdg-run/speech-dispatcher (read-only)", + "X11 (can see other windows)", + "Smart cards", + "Sound", + "Printing", + "Network", + "Notifications", + ]); + assertEquals(sandboxScore(grants), "high"); + const calc = summarize(parsePermissions("[Context]\nsockets=wayland;fallback-x11;\ndevices=dri;\n")); + assertEquals(calc, [{ text: "Devices: dri", level: "low" }]); + assertEquals(sandboxScore(calc), "low"); + assertEquals(sandboxScore(summarize(parsePermissions("[Context]\nfilesystems=home;\n"))), "high"); +}); + +Deno.test("links and uninstall arguments", () => { + const [firefox, flatseal, builder] = parseList(APPS, "app"); + assertEquals(flathubUrl(firefox), "https://flathub.org/apps/org.mozilla.firefox"); + assertEquals(flathubUrl(builder), undefined); + assertEquals(uninstallArgs(flatseal), [ + "uninstall", + "--user", + "--noninteractive", + "-y", + "app/com.github.tchx84.Flatseal/x86_64/stable", + ]); + assertEquals(uninstallArgs(firefox, true).slice(1, 2), ["--system"]); + assertEquals(uninstallArgs(firefox, true).includes("--delete-data"), true); +}); diff --git a/extensions/fonts/backslash.toml b/extensions/fonts/backslash.toml new file mode 100644 index 0000000..ce0b2bd --- /dev/null +++ b/extensions/fonts/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "fonts" +title = "Fonts" +description = "Browse installed font families with previews, copy their CSS and install new ones" +version = "1.0.0" +author = "Backslash" +icon = "bs-font" +license = "MIT" + +# fc-list and fc-cache (fontconfig) list fonts and refresh the cache after an install. +# pango-view (Pango) or ImageMagick's convert, when installed, render previews +# into ~/.cache/backslash/fonts. cp copies a font file from wherever you typed, +# so the extension needs no read access to your folders. +[permissions] +run = ["fc-list", "fc-cache", "pango-view", "convert", "cp"] +read = ["~/.cache/backslash/fonts", "~/.local/share/fonts"] +write = ["~/.cache/backslash/fonts", "~/.local/share/fonts"] +env = ["HOME"] + +[[commands]] +name = "fonts" +title = "Fonts" +description = "Installed font families, with a preview, their styles and files" +icon = "bs-font" +keywords = [ + "font", + "fonts", + "police", + "polices", + "typeface", + "typographie", + "typography", + "monospace", + "css", + "font-family", + "fontconfig", +] + +[[commands]] +name = "install-font" +title = "Install Font" +description = "Copy a .ttf or .otf file into your fonts folder" +icon = "bs-download" +keywords = ["font", "police", "install", "installer", "ttf", "otf", "ajouter"] diff --git a/extensions/fonts/commands/fonts.ts b/extensions/fonts/commands/fonts.ts new file mode 100644 index 0000000..a461a4c --- /dev/null +++ b/extensions/fonts/commands/fonts.ts @@ -0,0 +1,267 @@ +import { + type Action, + action, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + basename, + cssFontFamily, + dirname, + type Family, + FC_FORMAT, + type FontFace, + groupFamilies, + isFontFile, + pangoFont, + parseFcList, + plural, + previewName, + regularFile, + regularStyle, + SAMPLE, + SAMPLE_EXTRA, + tilde, +} from "../lib.ts"; +import { HOME, installForm, LEGACY_FONTS, PREVIEWS, USER_FONTS } from "../install.ts"; + +type Renderer = "pango-view" | "convert"; + +/** pango-view (Pango) renders by family name; ImageMagick's convert by file. */ +async function findRenderer(): Promise { + for (const [program, flag] of [["pango-view", "--version"], ["convert", "-version"]] as const) { + try { + await exec(program, [flag]); + return program; + } catch (err) { + if (err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable) continue; + return program; // Installed, but grumpy about the flag. + } + } + return undefined; +} + +async function render(renderer: Renderer, fam: Family, file: string | undefined, out: string): Promise { + const text = `${fam.name}\n${SAMPLE}\n${SAMPLE_EXTRA}`; + const markup = `${escapeMarkup(fam.name)}\n${escapeMarkup(`${SAMPLE}\n${SAMPLE_EXTRA}`)}`; + try { + if (renderer === "pango-view") { + // Through a file: pango-view decodes arguments with the locale, which may not be UTF-8. + const input = `${out}.txt`; + await Deno.writeTextFile(input, markup); + await exec("pango-view", [ + "-q", + `--font=${pangoFont(fam.name, 26)}`, + "--background=#ffffff", + "--foreground=#1a1a1a", + "--margin=28", + "--dpi=96", + "--markup", + `--output=${out}`, + input, + ]).finally(() => Deno.remove(input).catch(() => {})); + } else { + if (!file) return false; + await exec("convert", [ + "-background", + "#ffffff", + "-fill", + "#1a1a1a", + "-font", + file, + "-pointsize", + "26", + "-bordercolor", + "#ffffff", + "-border", + "28", + `label:${text}`, + out, + ]); + } + return true; + } catch { + return false; + } +} + +const escapeMarkup = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + +const exists = (path: string) => Deno.stat(path).then(() => true, () => false); + +function sampleMarkdown(fam: Family, home: string): string { + const files = fam.files.slice(0, 12).map((f) => `- \`${tilde(f, home)}\``); + if (fam.files.length > 12) files.push(`- … and ${fam.files.length - 12} more`); + return [ + "```", + SAMPLE, + SAMPLE.toUpperCase(), + SAMPLE_EXTRA, + fam.mono ? "fn main() { let x = 0x1F; } // 0O Il1| -> => != ===" : "", + "```", + "", + `**${plural(fam.files.length, "file")}**`, + "", + ...files, + ].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n"); +} + +let total = 0; +const families = (n: number) => `${n} ${n === 1 ? "family" : "families"}`; +let rendering = ""; + +function item(fam: Family, faces: FontFace[], preview?: string): Item { + const css = cssFontFamily(fam.name, fam.mono); + const folder = dirname(fam.files[0] ?? USER_FONTS); + const removable = fam.files.filter((f) => f.startsWith(USER_FONTS + "/")); + const actions: Action[] = [ + copy(fam.name, "Copy Family Name"), + copy(css, "Copy CSS font-family", { icon: Icons.braces, shortcut: "c", section: "Copy" }), + copy(fam.files.join("\n"), fam.files.length === 1 ? "Copy File Path" : "Copy File Paths", { + icon: Icons.file, + section: "Copy", + }), + open(folder, "Open Folder", { icon: Icons.folder, shortcut: "o" }), + action("Install Font…", () => installForm("", false), { icon: Icons.plus, shortcut: "n" }), + ]; + if (removable.length) { + actions.push(action("Uninstall", async () => { + for (const f of removable) await Deno.remove(f); + await exec("fc-cache", ["-f", USER_FONTS]).catch(() => {}); + await toast(`${fam.name} uninstalled`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${plural(removable.length, "file")} of ${fam.name} from ~/.local/share/fonts?`, + section: "Danger", + })); + } + return { + id: fam.name, + title: fam.name, + subtitle: plural(fam.styles.length, "style"), + icon: { source: Icons.font, tint: fam.user ? "purple" : fam.mono ? "teal" : "blue" }, + section: fam.user ? "Your Fonts" : "System Fonts", + keywords: [...fam.styles, ...fam.formats, fam.mono ? "monospace" : "", fam.user ? "user" : ""], + accessories: [ + ...(fam.mono ? [{ tag: "Mono", color: "teal" as const }] : []), + { text: fam.formats.join(" · "), tooltip: "File formats" }, + ], + detail: { + image: preview, + markdown: sampleMarkdown(fam, HOME), + metadata: [ + meta.text("Family", fam.name, { icon: Icons.font }), + meta.tags( + "Styles", + fam.styles.map((s) => ({ text: s, color: s === regularStyle(fam.styles) ? "blue" : undefined })), + ), + meta.text("Spacing", fam.mono ? "Monospace" : "Proportional", fam.mono ? { color: "teal" } : {}), + meta.tags("Formats", fam.formats), + meta.separator(), + meta.text("Installed", fam.user ? "For you" : "System-wide", { icon: fam.user ? Icons.person : Icons.server }), + meta.text("Folder", tilde(folder, HOME), { icon: Icons.folder }), + ...(regularFile(faces, fam.name) ? [meta.text("Regular file", basename(regularFile(faces, fam.name)!))] : []), + meta.text("CSS", css, { icon: Icons.code }), + ], + }, + actions, + }; +} + +export default list({ + placeholder: "Search font families, styles…", + filter: { + options: [ + { value: "all", title: "All Fonts" }, + { value: "mono", title: "Monospace" }, + { value: "user", title: "Yours" }, + ], + }, + empty: { + title: "No fonts found", + description: "Press Ctrl+N to install one from a .ttf or .otf file, or type its path in the search bar", + icon: Icons.font, + }, + status: (items) => + `${items.length === total ? families(total) : `${items.length} of ${families(total)}`}${ + rendering ? ` · ${rendering}` : "" + }`, + async *items({ filter, query, signal }) { + let out: string; + try { + out = await exec("fc-list", ["--format", FC_FORMAT]); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + throw new Error("fc-list was not found: install fontconfig (it ships with every desktop)"); + } + throw err; + } + const faces = parseFcList(out); + const all = groupFamilies(faces, [USER_FONTS, LEGACY_FONTS]); + let shown = all; + updateCommandMetadata({ subtitle: families(all.length) }).catch(() => {}); + if (filter === "mono") shown = all.filter((f) => f.mono); + if (filter === "user") shown = all.filter((f) => f.user); + total = shown.length; + + // A font file path typed before opening: offer to install it first. + const install: Item[] = isFontFile(query.trim()) + ? [{ + id: "install", + title: `Install ${basename(query.trim())}`, + subtitle: "Copy into ~/.local/share/fonts", + icon: { source: Icons.download, tint: "green" }, + section: "Install", + keywords: [query], + actions: [action("Install Font", () => installForm(query.trim(), false), { icon: Icons.download })], + }] + : []; + + const previews = new Map(); + await Promise.all(shown.map(async (f) => { + const path = `${PREVIEWS}/${previewName(f.name)}`; + if (await exists(path)) previews.set(f.name, path); + })); + const build = () => [...install, ...shown.map((f) => item(f, faces, previews.get(f.name)))]; + yield build(); + + const missing = shown.filter((f) => !previews.has(f.name)); + if (missing.length === 0) return; + const renderer = await findRenderer(); + if (!renderer) return; + await Deno.mkdir(PREVIEWS, { recursive: true }); + let done = 0; + let since = 0; + const queue = [...missing]; + const worker = async () => { + for (let fam = queue.shift(); fam && !signal.aborted; fam = queue.shift()) { + const path = `${PREVIEWS}/${previewName(fam.name)}`; + if (await render(renderer, fam, regularFile(faces, fam.name), path)) previews.set(fam.name, path); + done++; + since++; + } + }; + const workers = Promise.all(Array.from({ length: 4 }, worker)); + let finished = false; + workers.finally(() => finished = true); + while (!finished && !signal.aborted) { + await Promise.race([workers, new Promise((r) => setTimeout(r, 1500))]); + rendering = finished ? "" : `rendering previews ${done}/${missing.length}`; + if (since > 0 || finished) { + since = 0; + yield build(); + } + } + rendering = ""; + }, +}); diff --git a/extensions/fonts/commands/install-font.ts b/extensions/fonts/commands/install-font.ts new file mode 100644 index 0000000..ac6591f --- /dev/null +++ b/extensions/fonts/commands/install-font.ts @@ -0,0 +1,5 @@ +import type { Context } from "@backslash/api"; +import { installForm } from "../install.ts"; + +// A path typed before opening the command fills the form, e.g. "Install Font ~/Downloads/Inter.ttf". +export default (ctx: Context) => installForm(ctx.query.trim(), true); diff --git a/extensions/fonts/install.ts b/extensions/fonts/install.ts new file mode 100644 index 0000000..dcbbddd --- /dev/null +++ b/extensions/fonts/install.ts @@ -0,0 +1,52 @@ +import { close, exec, field, form, type FormSpec } from "@backslash/api"; +import { basename, expandPath, FONT_EXTENSIONS, isFontFile } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +/** Where fontconfig looks for the user's own fonts (XDG). */ +export const USER_FONTS = `${HOME}/.local/share/fonts`; +/** The legacy folder, still read by fontconfig. */ +export const LEGACY_FONTS = `${HOME}/.fonts`; +export const PREVIEWS = `${HOME}/.cache/backslash/fonts`; + +/** Copies a font file into ~/.local/share/fonts and refreshes fontconfig's cache. Returns the new path. */ +export async function installFont(input: string): Promise { + const source = expandPath(input, HOME); + if (!source) throw new Error("Type the path of a font file (.ttf, .otf…)"); + if (!source.startsWith("/")) throw new Error(`Use an absolute path, e.g. ~/Downloads/${basename(source)}`); + if (!isFontFile(source)) { + throw new Error( + `Not a font file: ${basename(source)} (expected ${FONT_EXTENSIONS.map((e) => `.${e}`).join(", ")})`, + ); + } + await Deno.mkdir(USER_FONTS, { recursive: true }); + const target = `${USER_FONTS}/${basename(source)}`; + if (await Deno.stat(target).then(() => true, () => false)) { + throw new Error(`${basename(source)} is already installed in ~/.local/share/fonts`); + } + // cp reads the file wherever it is, so the extension needs no read access to your folders. + await exec("cp", ["--no-clobber", "--", source, target]).catch((err) => { + const msg = String(err?.message ?? err); + if (/No such file/i.test(msg)) throw new Error(`File not found: ${source}`); + throw new Error(msg.replace(/^cp exited with \d+: /, "")); + }); + await exec("fc-cache", ["-f", USER_FONTS]).catch(() => {}); + return target; +} + +export function installForm(path: string, standalone: boolean): FormSpec { + return form({ + title: "Install Font", + submitTitle: "Install", + fields: [ + field.text("path", "Font file", { + required: true, + placeholder: "~/Downloads/Inter-Regular.ttf", + value: path, + }), + ], + async submit(values) { + const target = await installFont(String(values.path)); + return close(`Installed ${basename(target)}${standalone ? "" : ": reopen Fonts to see it"}`); + }, + }); +} diff --git a/extensions/fonts/lib.ts b/extensions/fonts/lib.ts new file mode 100644 index 0000000..757ed5e --- /dev/null +++ b/extensions/fonts/lib.ts @@ -0,0 +1,168 @@ +// Pure helpers for the Fonts extension: fc-list parsing, grouping and naming. + +/** One font file as fc-list describes it. */ +export interface FontFace { + family: string; + style: string; + file: string; + /** fontconfig spacing: 0 proportional, 90 dual width, 100 monospace, 110 charcell. */ + spacing?: number; +} + +/** All the faces of one family. */ +export interface Family { + name: string; + styles: string[]; + files: string[]; + mono: boolean; + /** Installed in the user's own font folder. */ + user: boolean; + /** Formats of its files, e.g. ["TTF", "OTF"]. */ + formats: string[]; +} + +/** The `fc-list --format` that {@link parseFcList} reads: first family and style, file, spacing. */ +export const FC_FORMAT = "%{family[0]}\\t%{style[0]}\\t%{file}\\t%{spacing}\\n"; + +/** Files fontconfig can install, by extension. */ +export const FONT_EXTENSIONS = ["ttf", "otf", "ttc", "otc", "woff", "woff2", "pfb", "pcf", "bdf"]; + +export function parseFcList(out: string): FontFace[] { + const faces: FontFace[] = []; + for (const line of out.split("\n")) { + const [family, style, file, spacing] = line.split("\t"); + if (!family?.trim() || !file?.trim()) continue; + const n = Number(spacing); + faces.push({ + family: family.trim(), + style: style?.trim() || "Regular", + file: file.trim(), + spacing: spacing?.trim() && Number.isFinite(n) ? n : undefined, + }); + } + return faces; +} + +/** Usual order of styles: weights from thin to black, upright before italic. */ +const WEIGHTS = [ + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "book", + "regular", + "normal", + "roman", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "heavy", +]; + +export function styleRank(style: string): number { + const s = style.toLowerCase().replace(/[\s-]/g, ""); + const italic = /italic|oblique/.test(s) ? 0.5 : 0; + const condensed = /condensed|narrow/.test(s) ? 100 : 0; + const bare = s.replace(/italic|oblique|condensed|narrow/g, ""); + let weight = WEIGHTS.indexOf(bare || "regular"); + if (weight < 0) weight = WEIGHTS.findIndex((w) => bare.startsWith(w)); + if (weight < 0) weight = WEIGHTS.indexOf("regular"); + return condensed + weight + italic; +} + +export function sortStyles(styles: string[]): string[] { + return [...styles].sort((a, b) => styleRank(a) - styleRank(b) || a.localeCompare(b)); +} + +const isMono = (spacing?: number) => spacing === 90 || spacing === 100 || spacing === 110; + +export function fileFormat(file: string): string { + return (file.split(".").pop() ?? "").toUpperCase(); +} + +/** Faces grouped into families, sorted by name. `userDirs` are the folders the user installs fonts into. */ +export function groupFamilies(faces: FontFace[], userDirs: string[] = []): Family[] { + const map = new Map(); + for (const f of faces) { + let fam = map.get(f.family); + if (!fam) { + fam = { name: f.family, styles: [], files: [], mono: false, user: false, formats: [] }; + map.set(f.family, fam); + } + if (!fam.styles.includes(f.style)) fam.styles.push(f.style); + if (!fam.files.includes(f.file)) fam.files.push(f.file); + const format = fileFormat(f.file); + if (format && !fam.formats.includes(format)) fam.formats.push(format); + if (isMono(f.spacing)) fam.mono = true; + if (userDirs.some((d) => f.file.startsWith(d.endsWith("/") ? d : d + "/"))) fam.user = true; + } + return [...map.values()] + .map((f) => ({ ...f, styles: sortStyles(f.styles), files: f.files.sort() })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +/** The style to preview: Regular when there is one. */ +export function regularStyle(styles: string[]): string { + return styles.find((s) => /^(regular|normal|book|roman)$/i.test(s)) ?? sortStyles(styles)[0] ?? "Regular"; +} + +/** The file of a family's regular face, for renderers that take a file. */ +export function regularFile(faces: FontFace[], family: string): string | undefined { + const own = faces.filter((f) => f.family === family); + const style = regularStyle(own.map((f) => f.style)); + return own.find((f) => f.style === style)?.file ?? own[0]?.file; +} + +const GENERIC = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]); + +/** `font-family: "Fira Code", monospace;` */ +export function cssFontFamily(name: string, mono: boolean): string { + const quoted = GENERIC.has(name.toLowerCase()) ? name : /^[A-Za-z][\w-]*$/.test(name) ? name : JSON.stringify(name); + return `font-family: ${quoted}, ${ + mono ? "monospace" : /serif/i.test(name) && !/sans/i.test(name) ? "serif" : "sans-serif" + };`; +} + +/** A file name for the cached preview of a family. */ +export function previewName(family: string): string { + const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "font"; + // A short hash keeps families that differ only by punctuation apart. + let h = 0; + for (const c of family) h = (h * 31 + c.codePointAt(0)!) >>> 0; + return `${slug}-${h.toString(36)}.png`; +} + +/** The pango font description of a family, safe for families whose name ends in a style word. */ +export const pangoFont = (family: string, size: number) => `${family}, ${size}`; + +export const SAMPLE = "The quick brown fox jumps over the lazy dog"; +export const SAMPLE_EXTRA = "0123456789 ({[<>]}) &@#%?! àéîõü ß Ω"; + +/** A path typed in the search bar, with `~` and `file://` understood. */ +export function expandPath(input: string, home: string): string { + let p = input.trim().replace(/^["']|["']$/g, ""); + if (p.startsWith("file://")) p = decodeURIComponent(p.slice(7)); + if (p === "~" || p.startsWith("~/")) p = home + p.slice(1); + return p; +} + +export function isFontFile(path: string): boolean { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return path.includes("/") && FONT_EXTENSIONS.includes(ext); +} + +export const basename = (p: string) => p.split("/").pop() ?? p; +export const dirname = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"; + +export function tilde(p: string, home: string): string { + return home && (p === home || p.startsWith(home + "/")) ? "~" + p.slice(home.length) : p; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} diff --git a/extensions/fonts/lib_test.ts b/extensions/fonts/lib_test.ts new file mode 100644 index 0000000..8d8b8f1 --- /dev/null +++ b/extensions/fonts/lib_test.ts @@ -0,0 +1,107 @@ +import { assert, assertEquals } from "@std/assert"; +import { + cssFontFamily, + expandPath, + groupFamilies, + isFontFile, + pangoFont, + parseFcList, + previewName, + regularFile, + regularStyle, + sortStyles, + tilde, +} from "./lib.ts"; + +// fc-list --format '%{family[0]}\t%{style[0]}\t%{file}\t%{spacing}\n' on Ubuntu 24.04, plus a user font. +const FC_LIST = [ + "JetBrains Mono\tMedium\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Medium.ttf\t100", + "JetBrains Mono\tBold Italic\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-BoldItalic.ttf\t100", + "JetBrains Mono\tRegular\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf\t100", + "JetBrains Mono\tThin\t/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Thin.ttf\t100", + "Liberation Serif\tBold Italic\t/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf\t", + "Liberation Serif\tRegular\t/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf\t", + "Inter Display\tSemiBold Italic\t/usr/share/fonts/opentype/inter/InterDisplay-SemiBoldItalic.otf\t", + "Inter Display\tLight\t/usr/share/fonts/opentype/inter/InterDisplay-Light.otf\t", + "DejaVu Sans\tBook\t/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\t", + "DejaVu Sans\tBold\t/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\t", + "Fira Code\tRegular\t/home/ada/.local/share/fonts/FiraCode-Regular.ttf\t100", + "Fira Code\tRegular\t/home/ada/.local/share/fonts/FiraCode-Regular.woff2\t100", + "\t\t\t", + "", +].join("\n"); + +Deno.test("parseFcList reads faces and skips blank lines", () => { + const faces = parseFcList(FC_LIST); + assertEquals(faces.length, 12); + assertEquals(faces[0], { + family: "JetBrains Mono", + style: "Medium", + file: "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Medium.ttf", + spacing: 100, + }); + assertEquals(faces[4].spacing, undefined); +}); + +Deno.test("groupFamilies merges faces, orders styles and spots user fonts", () => { + const fams = groupFamilies(parseFcList(FC_LIST), ["/home/ada/.local/share/fonts", "/home/ada/.fonts"]); + assertEquals(fams.map((f) => f.name), [ + "DejaVu Sans", + "Fira Code", + "Inter Display", + "JetBrains Mono", + "Liberation Serif", + ]); + const jb = fams.find((f) => f.name === "JetBrains Mono")!; + assertEquals(jb.styles, ["Thin", "Regular", "Medium", "Bold Italic"]); + assert(jb.mono); + assert(!jb.user); + const fira = fams.find((f) => f.name === "Fira Code")!; + assert(fira.user); + assertEquals(fira.styles, ["Regular"]); + assertEquals(fira.formats, ["TTF", "WOFF2"]); + assert(!fams.find((f) => f.name === "Liberation Serif")!.mono); +}); + +Deno.test("sortStyles puts weights in order, italics after uprights", () => { + assertEquals( + sortStyles(["Bold", "Italic", "Black", "Light Italic", "Regular", "Condensed Bold", "SemiBold"]), + ["Light Italic", "Regular", "Italic", "SemiBold", "Bold", "Black", "Condensed Bold"], + ); +}); + +Deno.test("regularStyle and regularFile pick the upright face", () => { + assertEquals(regularStyle(["Bold", "Book", "Oblique"]), "Book"); + assertEquals(regularStyle(["Light", "SemiBold Italic"]), "Light"); + const faces = parseFcList(FC_LIST); + assertEquals( + regularFile(faces, "JetBrains Mono"), + "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf", + ); + assertEquals(regularFile(faces, "Nope"), undefined); +}); + +Deno.test("cssFontFamily quotes names and adds a generic family", () => { + assertEquals(cssFontFamily("JetBrains Mono", true), `font-family: "JetBrains Mono", monospace;`); + assertEquals(cssFontFamily("Inter", false), "font-family: Inter, sans-serif;"); + assertEquals(cssFontFamily("Liberation Serif", false), `font-family: "Liberation Serif", serif;`); + assertEquals(cssFontFamily("Noto Sans", false), `font-family: "Noto Sans", sans-serif;`); +}); + +Deno.test("previewName is a safe, distinct file name", () => { + assert(/^[a-z0-9-]+\.png$/.test(previewName("Noto Sans CJK JP"))); + assert(previewName("Foo-Bar").startsWith("foo-bar-")); + assert(previewName("Foo Bar") !== previewName("Foo-Bar")); + assertEquals(pangoFont("Iosevka Term Bold", 26), "Iosevka Term Bold, 26"); +}); + +Deno.test("expandPath and isFontFile understand typed paths", () => { + assertEquals(expandPath("~/Downloads/Inter.ttf", "/home/ada"), "/home/ada/Downloads/Inter.ttf"); + assertEquals(expandPath("file:///tmp/My%20Font.otf", "/home/ada"), "/tmp/My Font.otf"); + assertEquals(expandPath(" '/tmp/a.ttf' ", "/home/ada"), "/tmp/a.ttf"); + assert(isFontFile("~/Downloads/Inter.TTF")); + assert(!isFontFile("Inter.ttf")); + assert(!isFontFile("/tmp/notes.txt")); + assertEquals(tilde("/home/ada/.local/share/fonts", "/home/ada"), "~/.local/share/fonts"); + assertEquals(tilde("/home/adam/x", "/home/ada"), "/home/adam/x"); +}); diff --git a/extensions/git-repos/backslash.toml b/extensions/git-repos/backslash.toml new file mode 100644 index 0000000..ee256a6 --- /dev/null +++ b/extensions/git-repos/backslash.toml @@ -0,0 +1,85 @@ +[extension] +id = "git-repos" +title = "Git Repos" +description = "Your local git repositories with branch, changes, ahead/behind and last commit, plus fetch, pull and stash" +version = "1.0.0" +author = "Backslash" +icon = "bs-branch" +license = "MIT" + +[permissions] +env = ["HOME"] +# To suggest how to install git when it is missing. +read = ["/etc/os-release"] +# find scans the folders set in the preferences (which a fixed read list +# couldn't cover); git reads the repositories it finds. Then the editors and +# terminals the repositories open in. +run = [ + "git", + "find", + "code", + "codium", + "cursor", + "zed", + "zeditor", + "subl", + "idea", + "gnome-text-editor", + "kate", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "repos" +title = "Git Repositories" +description = "Repositories in your code folders: changes, ahead/behind, last commit; fetch, pull, stash, switch branch" +icon = "bs-branch" +keywords = [ + "git", + "repo", + "repository", + "branch", + "status", + "fetch", + "pull", + "stash", + "commit", + "dépôt", + "branche", + "projet", +] + +[[preferences]] +name = "folders" +title = "Folders" +description = "Where to look for repositories, separated by commas" +default = "~/Projects, ~/projects, ~/code, ~/Code, ~/src, ~/dev, ~/git, ~/repos, ~/workspace" + +[[preferences]] +name = "depth" +title = "Scan depth" +description = "How many folders deep to look below each folder (1 to 8)" +type = "number" +default = 3 + +[[preferences]] +name = "editor" +title = "Editor" +description = "Command opening a repository: code, codium, cursor, zed, subl, idea, kate, gnome-text-editor, or nvim, hx, vim, nano, micro (in the terminal)" +default = "code" + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Leave empty to use the first one installed: kitty, alacritty, wezterm, foot, ghostty, ptyxis, kgx, gnome-terminal, konsole, xfce4-terminal, tilix, xterm" diff --git a/extensions/git-repos/commands/repos.ts b/extensions/git-repos/commands/repos.ts new file mode 100644 index 0000000..4b82410 --- /dev/null +++ b/extensions/git-repos/commands/repos.ts @@ -0,0 +1,403 @@ +import { + type Action, + action, + close, + type Color, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { git, GitMissing, HOME, inspect, pool, recentCommits, type Repo, run } from "../git.ts"; +import { + branchUrl, + changeSummary, + type Commit, + commitUrl, + findArgs, + installCommand, + parseBranches, + parseFolders, + relativeTime, + reposFromFind, + rootOf, + tilde, +} from "../lib.ts"; +import { launch, openTerminalAt, runInTerminal } from "../terminal.ts"; + +interface Prefs { + folders?: string; + depth?: number; + editor?: string; + terminal?: string; +} + +/** Editors that live in a terminal. */ +const TERMINAL_EDITORS = new Set(["nvim", "vim", "vi", "hx", "helix", "nano", "micro", "kak", "emacs"]); + +const EDITOR_NAMES: Record = { + code: "VS Code", + codium: "VSCodium", + cursor: "Cursor", + zed: "Zed", + zeditor: "Zed", + subl: "Sublime Text", + idea: "IntelliJ IDEA", + "gnome-text-editor": "Text Editor", + kate: "Kate", + nvim: "Neovim", + hx: "Helix", +}; + +const name = (path: string) => path.split("/").pop() || path; + +function openInEditor(path: string, prefs: Prefs) { + const editor = prefs.editor?.trim() || "code"; + if (TERMINAL_EDITORS.has(editor)) { + runInTerminal([editor, "."], prefs.terminal || undefined, path); + return; + } + if (!launch(editor, [path])) throw new Error(`${editor} is not installed: set your editor in the preferences`); +} + +/** Runs a git command from an action: a toast with its outcome, then the list reloads. */ +function gitAction( + title: string, + repo: Repo, + args: string[], + done: string, + options: { icon?: string; shortcut?: string; section?: string; confirm?: string; destructive?: boolean } = {}, +): Action { + return action(title, async () => { + await toast(`${title}…`); + try { + const out = await git(repo.path, args); + const summary = out.split("\n").filter(Boolean).pop(); + await toast(done, { style: "success", message: summary && summary.length < 120 ? summary : undefined }); + } catch (e) { + await toast(`${title} failed`, { style: "failure", message: (e as Error).message }); + } + return refresh(); + }, options); +} + +function commitItem(c: Commit, repo: Repo, now: number): Item { + const refs = c.refs.map((r) => r.replace(/^HEAD -> /, "")).filter((r) => r !== "HEAD"); + const page = repo.remote?.page; + return { + id: c.hash, + title: c.subject, + subtitle: c.short, + icon: { source: Icons.commit, tint: refs.length ? "blue" : "gray" }, + keywords: [c.author, c.hash, ...refs], + accessories: [ + ...refs.slice(0, 2).map((r) => ({ + tag: r.replace(/^tag: /, ""), + color: (r.startsWith("tag: ") ? "yellow" : "blue") as Color, + })), + { avatar: c.author, tooltip: c.author }, + { text: relativeTime(c.time, now) }, + ], + detail: { + markdown: `## ${c.subject}${c.body ? `\n\n${c.body}` : ""}`, + metadata: [ + meta.text("Commit", c.short, { icon: Icons.commit }), + meta.person("Author", c.author), + meta.text("Email", c.email, { icon: Icons.mail }), + meta.text("Date", new Date(c.time * 1000).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + ...(refs.length ? [meta.tags("Refs", refs)] : []), + ], + }, + actions: [ + copy(c.hash, "Copy Commit Hash"), + ...(page ? [open(commitUrl(page, c.hash), "Open Commit in Browser", { shortcut: "o" })] : []), + action("Show Changed Files", async () => { + const stat = await git(repo.path, ["show", "--stat", "--format=", c.hash]); + return detail({ + title: c.short, + markdown: `## ${c.subject}\n\n\`\`\`\n${stat || "No file changes."}\n\`\`\``, + metadata: [meta.text("Commit", c.short), meta.person("Author", c.author)], + actions: [copy(c.hash, "Copy Commit Hash")], + }); + }, { icon: Icons.file, shortcut: "d" }), + copy(c.short, "Copy Short Hash", { section: "Copy" }), + copy(c.subject, "Copy Message", { icon: Icons.text, section: "Copy" }), + ], + }; +} + +function commitsList(repo: Repo): ListSpec { + return list({ + title: `${name(repo.path)} · Recent Commits`, + placeholder: "Filter commits by message, author, hash…", + status: (items) => `${items.length} recent commits on ${repo.status?.branch ?? "HEAD"}`, + empty: { title: "No commits yet", icon: Icons.commit }, + async items({ signal }) { + const now = Date.now(); + return (await recentCommits(repo.path, signal)).map((c) => commitItem(c, repo, now)); + }, + }); +} + +function repoActions(repo: Repo, prefs: Prefs): Action[] { + const s = repo.status; + const page = repo.remote?.page; + const editor = prefs.editor?.trim() || "code"; + return [ + action(`Open in ${EDITOR_NAMES[editor] ?? editor}`, () => { + openInEditor(repo.path, prefs); + return close(); + }, { icon: Icons.code }), + action("Open in Terminal", () => { + openTerminalAt(repo.path, prefs.terminal || undefined); + return close(); + }, { icon: Icons.terminal, shortcut: "t" }), + action("Recent Commits", () => commitsList(repo), { icon: Icons.history, shortcut: "l" }), + ...(page ? [open(page, "Open Remote in Browser", { shortcut: "o" })] : []), + submenu("Switch Branch", async () => { + const branches = parseBranches( + await git(repo.path, ["branch", "--sort=-committerdate", "--format=%(refname:short)"]), + ); + return branches.filter((b) => b !== s?.branch).slice(0, 40).map((b) => + gitAction(b, repo, ["switch", b], `Switched to ${b}`, { icon: Icons.branch }) + ); + }, { icon: Icons.branch, shortcut: "b", section: "Git" }), + gitAction("Fetch", repo, ["fetch", "--all", "--prune"], "Fetched", { + icon: Icons.download, + shortcut: "f", + section: "Git", + }), + ...(s?.upstream + ? [ + gitAction("Pull (Fast-Forward Only)", repo, ["pull", "--ff-only"], "Pulled", { + icon: Icons.arrowDown, + shortcut: "p", + section: "Git", + }), + ] + : []), + ...(repo.changes + ? [ + gitAction( + "Stash Changes", + repo, + [ + "stash", + "push", + "--include-untracked", + "-m", + `Backslash ${new Date().toISOString().slice(0, 16).replace("T", " ")}`, + ], + "Changes stashed", + { icon: Icons.archive, section: "Git" }, + ), + ] + : []), + ...(s?.stashes + ? [ + gitAction("Pop Latest Stash", repo, ["stash", "pop"], "Stash applied", { + icon: Icons.upload, + section: "Git", + confirm: "Apply and drop the latest stash?", + }), + ] + : []), + ...(page && s?.branch + ? [open(branchUrl(page, s.branch), "Open Branch in Browser", { icon: Icons.branch, section: "Open" })] + : []), + open(repo.path, "Open Folder", { icon: Icons.folder, section: "Open" }), + ...(repo.remote + ? [copy(repo.remote.url, "Copy Remote URL", { icon: Icons.link, shortcut: "c", section: "Copy" })] + : []), + copy(repo.path, "Copy Path", { section: "Copy" }), + ...(s?.branch ? [copy(s.branch, "Copy Branch Name", { icon: Icons.branch, section: "Copy" })] : []), + ...(repo.last ? [copy(repo.last.hash, "Copy Last Commit Hash", { icon: Icons.commit, section: "Copy" })] : []), + ]; +} + +function repoItem(repo: Repo, roots: string[], prefs: Prefs, now: number, loading: boolean): Item { + const s = repo.status; + const root = rootOf(repo.path, roots); + const parent = repo.path.slice(0, repo.path.length - name(repo.path).length - 1); + const accessories: Item["accessories"] = []; + if (s) { + accessories.push({ icon: Icons.branch, text: s.branch ?? `detached ${s.oid?.slice(0, 7) ?? ""}`.trim() }); + if (s.conflicts) accessories.push({ tag: `${s.conflicts} conflicts`, color: "red" }); + else if (repo.changes) { + accessories.push({ tag: `${repo.changes} changed`, color: "orange", tooltip: changeSummary(s) }); + } + if (s.ahead || s.behind) { + accessories.push({ + tag: [s.ahead && `↑${s.ahead}`, s.behind && `↓${s.behind}`].filter(Boolean).join(" "), + color: s.behind ? "purple" : "blue", + tooltip: `${s.ahead} to push, ${s.behind} to pull`, + }); + } + if (repo.last) accessories.push({ text: relativeTime(repo.last.time, now), tooltip: "Last commit" }); + } else if (repo.error) accessories.push({ tag: "Error", color: "red", tooltip: repo.error }); + const tint: Color = !s + ? "gray" + : s.conflicts + ? "red" + : repo.changes + ? "orange" + : s.ahead || s.behind + ? "blue" + : "green"; + const metadata: Metadata[] = [meta.text("Folder", tilde(repo.path, HOME), { icon: Icons.folder })]; + if (s) { + metadata.push( + meta.text("Branch", s.branch ?? "Detached HEAD", { icon: Icons.branch }), + ...(s.upstream + ? [meta.text("Upstream", s.upstream, { icon: Icons.cloud })] + : [meta.text("Upstream", "None", { color: "gray" })]), + meta.text("Changes", changeSummary(s), { + icon: repo.changes ? Icons.dot : Icons.check, + color: s.conflicts ? "red" : repo.changes ? "orange" : "green", + }), + ...(s.stashes ? [meta.text("Stashes", String(s.stashes), { icon: Icons.archive })] : []), + ); + } + if (repo.last) { + metadata.push( + meta.separator(), + meta.text("Last commit", repo.last.short, { icon: Icons.commit }), + meta.person("Author", repo.last.author), + meta.text("Date", relativeTime(repo.last.time, now), { icon: Icons.clock }), + ); + } + if (repo.remote) { + metadata.push(meta.separator()); + metadata.push( + repo.remote.page + ? meta.link(`Remote (${repo.remote.name})`, repo.remote.page.replace(/^https?:\/\//, ""), repo.remote.page) + : meta.text(`Remote (${repo.remote.name})`, repo.remote.url), + ); + } + return { + id: repo.path, + title: name(repo.path), + subtitle: tilde(parent, HOME), + icon: { source: Icons.repository, tint }, + section: root ? tilde(root, HOME) : undefined, + keywords: [repo.path, s?.branch ?? "", repo.remote?.url ?? ""], + accessories, + detail: { + stats: s + ? [ + { label: "changed", value: repo.changes, color: repo.changes ? "orange" : "green" }, + { label: "to push", value: s.ahead, color: s.ahead ? "blue" : undefined }, + { label: "to pull", value: s.behind, color: s.behind ? "purple" : undefined }, + ] + : [], + markdown: repo.error + ? `**git failed:** ${repo.error}` + : repo.last + ? `**${repo.last.subject}**\n\n${repo.last.author} · ${relativeTime(repo.last.time, now)}` + : loading + ? "_Reading git status…_" + : "_No commits yet._", + metadata, + }, + actions: repoActions(repo, prefs), + }; +} + +async function setupItems(error: GitMissing): Promise { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const cmd = installCommand(os); + return [{ + id: "setup", + title: error.message, + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { markdown: `## Install git\n\n\`\`\`\n${cmd}\n\`\`\`` }, + actions: [copy(cmd, "Copy Install Command"), open("https://git-scm.com/downloads/linux", "Open git Downloads")], + }]; +} + +let total = 0; + +export default list({ + placeholder: "Filter repositories by name, folder, branch, remote…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "changes", title: "Changes" }, + { value: "sync", title: "Ahead / Behind" }, + ], + }, + empty: { + title: "No repository here", + description: + "Set the folders to scan in Settings → Extensions → Git Repos (default: ~/Projects, ~/code, ~/src, ~/dev…)", + icon: Icons.repository, + }, + status: (items) => { + if (items[0]?.id === "setup") return "git is missing"; + if (items[0]?.id === "none") return "No repository found"; + return `${items.length === total ? total : `${items.length} of ${total}`} repositor${total === 1 ? "y" : "ies"}`; + }, + async *items({ preferences, signal, filter }) { + const prefs = preferences as Prefs; + const roots = parseFolders(prefs.folders, HOME); + const depth = Number(prefs.depth) || 3; + let paths: string[]; + try { + await run("git", ["--version"], { signal }); + // find exits with 1 when a folder doesn't exist; what it found is still good. + paths = reposFromFind(await run("find", findArgs(roots, depth), { signal, allowFailure: true })); + } catch (e) { + if (e instanceof GitMissing) { + yield await setupItems(e); + return; + } + throw e; + } + total = paths.length; + if (!paths.length) { + yield [{ + id: "none", + title: "No git repository found", + subtitle: `Looked ${depth} levels deep in ${roots.map((r) => tilde(r, HOME)).join(", ")}`, + icon: { source: Icons.folder, tint: "gray" }, + detail: { + markdown: + "Set **Folders** in Settings → Extensions → Git Repos → Configure: a list separated by commas, like `~/Projects, ~/work`.", + }, + actions: [open(roots[0] ?? HOME, "Open Folder")], + }]; + return; + } + const now = Date.now(); + const view = filter ?? "all"; + if (view === "all") yield paths.map((p) => repoItem({ path: p, changes: 0 }, roots, prefs, now, true)); + const repos = await pool(paths, 8, (p) => inspect(p, signal)); + const dirty = repos.filter((r) => r.changes > 0).length; + const unsynced = repos.filter((r) => r.status && (r.status.ahead || r.status.behind)).length; + updateCommandMetadata({ + subtitle: [dirty && `${dirty} with changes`, unsynced && `${unsynced} to sync`].filter(Boolean).join(" · ") || + null, + }).catch(() => {}); + const shown = repos.filter((r) => + view === "changes" + ? r.changes > 0 + : view === "sync" + ? !!r.status && (r.status.ahead > 0 || r.status.behind > 0) + : true + ); + total = repos.length; + yield shown.map((r) => repoItem(r, roots, prefs, now, false)); + }, +}); diff --git a/extensions/git-repos/git.ts b/extensions/git-repos/git.ts new file mode 100644 index 0000000..788ff9c --- /dev/null +++ b/extensions/git-repos/git.ts @@ -0,0 +1,89 @@ +// Running git and find. Never asks for credentials: a prompt would hang the launcher. + +import { type Commit, LOG_FORMAT, parseLog, parseStatus, pickRemote, remotePage, type Status } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +export class GitMissing extends Error {} + +const ENV = { GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes", LC_ALL: "C" }; + +/** Runs a program; throws its stderr on failure. `allowFailure` returns stdout anyway. */ +export async function run( + program: string, + args: string[], + options: { signal?: AbortSignal; allowFailure?: boolean; cwd?: string } = {}, +): Promise { + let out: Deno.CommandOutput; + try { + out = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "piped", + signal: options.signal, + env: ENV, + }).output(); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new GitMissing(`${program} is not installed`); + throw e; + } + const text = (b: Uint8Array) => new TextDecoder().decode(b).replace(/^\n+/, "").trimEnd(); + if (!out.success && !options.allowFailure) { + const message = text(out.stderr) || text(out.stdout) || `${program} failed`; + throw new Error(message.replace(/^(fatal|error): /gm, "").split("\n").slice(0, 3).join(" ")); + } + return text(out.stdout); +} + +export const git = (dir: string, args: string[], signal?: AbortSignal) => run("git", ["-C", dir, ...args], { signal }); + +export interface Repo { + path: string; + status?: Status; + /** Files differing from HEAD. */ + changes: number; + last?: Commit; + remote?: { name: string; url: string; page?: string }; + error?: string; +} + +/** Branch, changes, stashes, last commit and remote: three git calls. */ +export async function inspect(path: string, signal?: AbortSignal): Promise { + try { + const [statusOut, logOut, remotesOut] = await Promise.all([ + git(path, ["status", "--porcelain=v2", "--branch", "--show-stash"], signal), + git(path, ["log", "-1", `--format=${LOG_FORMAT}`], signal).catch(() => ""), + git(path, ["config", "--get-regexp", "^remote\\..*\\.url$"], signal).catch(() => ""), + ]); + const status = parseStatus(statusOut); + const remote = pickRemote(remotesOut); + return { + path, + status, + changes: status.staged + status.modified + status.untracked + status.conflicts, + last: parseLog(logOut)[0], + remote: remote ? { ...remote, page: remotePage(remote.url) } : undefined, + }; + } catch (e) { + if (e instanceof GitMissing) throw e; + return { path, changes: 0, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** Runs `fn` on every item, `limit` at a time. */ +export async function pool(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 recentCommits(path: string, signal?: AbortSignal): Promise { + return parseLog(await git(path, ["log", "-n", "30", `--format=${LOG_FORMAT}`], signal)); +} diff --git a/extensions/git-repos/lib.ts b/extensions/git-repos/lib.ts new file mode 100644 index 0000000..686129a --- /dev/null +++ b/extensions/git-repos/lib.ts @@ -0,0 +1,223 @@ +// Git output parsing, remote URLs and folder settings. Pure, tested in lib_test.ts. + +export interface Status { + /** Branch name; undefined when HEAD is detached. */ + branch?: string; + /** Commit hash, undefined in a repository without commits. */ + oid?: string; + upstream?: string; + ahead: number; + behind: number; + /** Files with staged changes. */ + staged: number; + /** Tracked files changed but not staged. */ + modified: number; + untracked: number; + conflicts: number; + stashes: number; +} + +/** `git status --porcelain=v2 --branch --show-stash`. */ +export function parseStatus(out: string): Status { + const s: Status = { ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0, conflicts: 0, stashes: 0 }; + for (const line of out.split("\n")) { + if (line.startsWith("# branch.oid ")) { + const oid = line.slice(13).trim(); + s.oid = oid === "(initial)" ? undefined : oid; + } else if (line.startsWith("# branch.head ")) { + const head = line.slice(14).trim(); + s.branch = head === "(detached)" ? undefined : head; + } else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18).trim(); + else if (line.startsWith("# branch.ab ")) { + const m = line.match(/\+(\d+) -(\d+)/); + if (m) [s.ahead, s.behind] = [Number(m[1]), Number(m[2])]; + } else if (line.startsWith("# stash ")) s.stashes = Number(line.slice(8)) || 0; + else if (line.startsWith("1 ") || line.startsWith("2 ")) { + // `1 XY …`: X is the index (staged), Y the work tree. + const [x, y] = [line[2], line[3]]; + if (x !== ".") s.staged++; + if (y !== ".") s.modified++; + } else if (line.startsWith("u ")) s.conflicts++; + else if (line.startsWith("? ")) s.untracked++; + } + return s; +} + +/** Files that differ from HEAD in any way (staged, modified, untracked or conflicting). */ +export function changedFiles(out: string): number { + return out.split("\n").filter((l) => /^[12u?] /.test(l)).length; +} + +export const isDirty = (s: Status) => s.staged + s.modified + s.untracked + s.conflicts > 0; + +export interface Commit { + hash: string; + short: string; + subject: string; + author: string; + email: string; + /** Unix seconds. */ + time: number; + /** `HEAD -> main, origin/main, tag: v1.0`. */ + refs: string[]; + body: string; +} + +/** The `--format` that {@link parseLog} reads. */ +export const LOG_FORMAT = "%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%ct%x1f%D%x1f%b%x1e"; + +export function parseLog(out: string): Commit[] { + return out.split("\x1e").map((r) => r.replace(/^\n/, "")).filter((r) => r.trim()).map((record) => { + const [hash, short, subject, author, email, time, refs, body] = record.split("\x1f"); + return { + hash, + short, + subject, + author, + email, + time: Number(time), + refs: (refs ?? "").split(",").map((r) => r.trim()).filter(Boolean), + body: (body ?? "").trim(), + }; + }); +} + +/** `git config --get-regexp ^remote\..*\.url$` → the origin's URL, else the first remote's. */ +export function pickRemote(out: string): { name: string; url: string } | undefined { + const remotes = out.split("\n").map((l) => l.match(/^remote\.(.+)\.url\s+(.+)$/)).filter((m): m is RegExpMatchArray => + !!m + ) + .map((m) => ({ name: m[1], url: m[2].trim() })); + return remotes.find((r) => r.name === "origin") ?? remotes[0]; +} + +/** `git@github.com:me/app.git`, `ssh://git@host:2222/me/app`, `https://user@host/me/app.git` → the web page. */ +export function remotePage(remote: string): string | undefined { + const r = remote.trim(); + if (!/^[a-z+]+:\/\//i.test(r)) { + // scp-like `user@host:path`; anything else is a local path. + const scp = r.match(/^(?:[^@/]+@)?([^:/]+):(.+?)(?:\.git)?\/?$/); + return scp ? `https://${scp[1]}/${scp[2].replace(/^\/+/, "")}` : undefined; + } + const ssh = r.match(/^(?:ssh|git\+ssh):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/); + if (ssh) return `https://${ssh[1]}/${ssh[2]}`; + const git = r.match(/^git:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/); + if (git) return `https://${git[1]}/${git[2]}`; + const https = r.match(/^(https?):\/\/(?:[^@/]+@)?(.+?)(?:\.git)?\/?$/); + return https ? `${https[1]}://${https[2]}` : undefined; +} + +/** A commit's page on the usual forges. */ +export function commitUrl(page: string, hash: string): string { + if (/bitbucket\.org/.test(page)) return `${page}/commits/${hash}`; + if (/gitlab/.test(page)) return `${page}/-/commit/${hash}`; + return `${page}/commit/${hash}`; +} + +/** A branch's page on the usual forges. */ +export function branchUrl(page: string, branch: string): string { + const b = branch.split("/").map(encodeURIComponent).join("/"); + if (/bitbucket\.org/.test(page)) return `${page}/src/${b}`; + if (/gitlab/.test(page)) return `${page}/-/tree/${b}`; + if (/codeberg\.org|gitea|forgejo/.test(page)) return `${page}/src/branch/${b}`; + return `${page}/tree/${b}`; +} + +export const DEFAULT_FOLDERS = "~/Projects, ~/projects, ~/code, ~/Code, ~/src, ~/dev, ~/git, ~/repos, ~/workspace"; + +/** The folders preference: commas, colons or new lines; `~` expanded; duplicates dropped. */ +export function parseFolders(pref: string | undefined, home: string): string[] { + const raw = (pref?.trim() || DEFAULT_FOLDERS).split(/[,:\n]/).map((f) => f.trim()).filter(Boolean); + const expanded = raw.map((f) => f.replace(/^~(?=\/|$)/, home).replace(/\/+$/, "") || "/"); + return [...new Set(expanded)].filter((f) => f.startsWith("/")); +} + +/** Directories never worth descending into. */ +const SKIP = ["node_modules", "vendor", "target", ".venv", "venv", "__pycache__", "build", "dist", ".cache"]; + +/** `find` arguments listing the `.git` entries at most `depth` folders below the roots. */ +export function findArgs(roots: string[], depth: number): string[] { + const skip = SKIP.flatMap((name, i) => [...(i ? ["-o"] : []), "-name", name]); + return [ + ...roots, + "-mindepth", + "1", + "-maxdepth", + String(Math.max(1, Math.min(8, Math.round(depth))) + 1), + "(", + ...skip, + "-o", + "(", + "-name", + ".*", + "!", + "-name", + ".git", + ")", + ")", + "-prune", + "-o", + "-name", + ".git", + "-print", + "-prune", + ]; +} + +/** `find` output → repository folders, sorted, without nested duplicates of the same path. */ +export function reposFromFind(out: string): string[] { + return [ + ...new Set(out.split("\n").map((l) => l.trim()).filter((l) => l.endsWith("/.git")).map((l) => l.slice(0, -5))), + ] + .sort((a, b) => a.localeCompare(b)); +} + +/** The configured folder a repository was found in. */ +export const rootOf = (path: string, roots: string[]) => + roots.filter((r) => path === r || path.startsWith(`${r}/`)).sort((a, b) => b.length - a.length)[0]; + +export const tilde = (path: string, home: string) => + home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + +/** "just now", "5 min ago", "3 h ago", "yesterday", "4 days ago", "2 weeks ago", "3 months ago", "2 years ago". */ +export function relativeTime(unixSeconds: number, now = Date.now()): string { + const s = Math.max(0, Math.round(now / 1000 - unixSeconds)); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + const d = Math.floor(s / 86400); + if (d === 1) return "yesterday"; + if (d < 14) return `${d} days ago`; + if (d < 60) return `${Math.floor(d / 7)} weeks ago`; + if (d < 730) return `${Math.floor(d / 30)} months ago`; + return `${Math.floor(d / 365)} years ago`; +} + +/** "2 staged, 3 modified, 1 untracked". */ +export function changeSummary(s: Status): string { + const parts = [ + s.conflicts && `${s.conflicts} conflicted`, + s.staged && `${s.staged} staged`, + s.modified && `${s.modified} modified`, + s.untracked && `${s.untracked} untracked`, + ].filter(Boolean); + return parts.length ? parts.join(", ") : "Clean"; +} + +/** Branch names from `git branch --format=%(refname:short)`. */ +export const parseBranches = (out: string) => + out.split("\n").map((b) => b.trim()).filter((b) => b && !b.startsWith("(")); + +/** `/etc/os-release` → how to install git. */ +export function installCommand(osRelease: string): string { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "sudo pacman -S git"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "sudo apt install git"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "sudo dnf install git"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "sudo zypper install git"; + if (ids.includes("alpine")) return "sudo apk add git"; + if (ids.includes("void")) return "sudo xbps-install git"; + if (ids.includes("nixos")) return "nix-env -iA nixos.git"; + return "Install git with your package manager"; +} diff --git a/extensions/git-repos/lib_test.ts b/extensions/git-repos/lib_test.ts new file mode 100644 index 0000000..cc7790f --- /dev/null +++ b/extensions/git-repos/lib_test.ts @@ -0,0 +1,178 @@ +import { assertEquals } from "@std/assert"; +import { + branchUrl, + changedFiles, + changeSummary, + commitUrl, + findArgs, + installCommand, + isDirty, + parseBranches, + parseFolders, + parseLog, + parseStatus, + pickRemote, + relativeTime, + remotePage, + reposFromFind, + rootOf, + tilde, +} from "./lib.ts"; +import { shellArgs, shellLine, terminalArgs } from "./terminal.ts"; + +// Real `git status --porcelain=v2 --branch --show-stash` output. +const DIRTY = `# branch.oid 667a1968df5af67e15fca1434f9ddfb3f3763836 +# branch.head dev +# stash 1 +1 .M N... 100644 100644 100644 78981922613b2afb6025042ff6bd878ac1994e85 78981922613b2afb6025042ff6bd878ac1994e85 a +1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 61780798228d17af2d34fce4cfbdf35556832472 b +2 R. N... 100644 100644 100644 d670460b4b4aece5915caf5c68d12f560a9fe3e4 d670460b4b4aece5915caf5c68d12f560a9fe3e4 R100 new.txt\told.txt +1 MM N... 100644 100644 100644 a1 b2 c.txt +u UU N... 100644 100644 100644 100644 a1 b2 c3 conflict.txt +? untracked +? notes/todo.md`; + +const SYNCED = `# branch.oid 90a3b337c2cca1109472e55837eef606bfbaa5ad +# branch.head main +# branch.upstream origin/main +# branch.ab +1 -3`; + +Deno.test("porcelain v2 status", () => { + assertEquals(parseStatus(DIRTY), { + oid: "667a1968df5af67e15fca1434f9ddfb3f3763836", + branch: "dev", + ahead: 0, + behind: 0, + staged: 3, + modified: 2, + untracked: 2, + conflicts: 1, + stashes: 1, + }); + assertEquals(changedFiles(DIRTY), 7); + const s = parseStatus(SYNCED); + assertEquals([s.branch, s.upstream, s.ahead, s.behind, isDirty(s)], ["main", "origin/main", 1, 3, false]); + const detached = parseStatus("# branch.oid d787b58\n# branch.head (detached)"); + assertEquals([detached.branch, detached.oid], [undefined, "d787b58"]); + const empty = parseStatus("# branch.oid (initial)\n# branch.head main\n? README.md"); + assertEquals([empty.oid, empty.branch, empty.untracked], [undefined, "main", 1]); + assertEquals(changeSummary(parseStatus(DIRTY)), "1 conflicted, 3 staged, 2 modified, 2 untracked"); + assertEquals(changeSummary(s), "Clean"); +}); + +Deno.test("log records", () => { + const out = + "90a3b337c2cca1109472e55837eef606bfbaa5ad\x1f90a3b33\x1fSecond: not pushed\x1fAda Lovelace\x1fada@example.com\x1f1793183949\x1fHEAD -> main, tag: v1.0, origin/main\x1fLonger body\n\nSigned-off-by: Ada\n\x1e\n" + + "b3511c4095f9d48b1fcc86a3f0e49d1fecf0f0be\x1fb3511c4\x1fFirst\x1fAda Lovelace\x1fada@example.com\x1f1793180000\x1f\x1f\x1e"; + const [a, b] = parseLog(out); + assertEquals([a.short, a.subject, a.author, a.time, a.refs], [ + "90a3b33", + "Second: not pushed", + "Ada Lovelace", + 1793183949, + ["HEAD -> main", "tag: v1.0", "origin/main"], + ]); + assertEquals(a.body, "Longer body\n\nSigned-off-by: Ada"); + assertEquals([b.refs, b.body], [[], ""]); + assertEquals(parseLog(""), []); +}); + +Deno.test("remotes and their web pages", () => { + const remotes = + "remote.upstream.url git@codeberg.org:ada/synced.git\nremote.origin.url https://github.com/ada/app.git"; + assertEquals(pickRemote(remotes), { name: "origin", url: "https://github.com/ada/app.git" }); + assertEquals(pickRemote("remote.fork.url git@x.org:a/b"), { name: "fork", url: "git@x.org:a/b" }); + assertEquals(pickRemote(""), undefined); + const cases: [string, string | undefined][] = [ + ["git@github.com:backslash-app/plugins.git", "https://github.com/backslash-app/plugins"], + ["git@codeberg.org:ada/notes", "https://codeberg.org/ada/notes"], + ["ssh://git@gitlab.example.com:2222/team/api.git", "https://gitlab.example.com/team/api"], + ["git+ssh://git@github.com/a/b.git", "https://github.com/a/b"], + ["https://user:token@gitlab.com/org/api.git", "https://gitlab.com/org/api"], + ["http://git.local/repo/", "http://git.local/repo"], + ["git://git.kernel.org/pub/scm/git/git.git", "https://git.kernel.org/pub/scm/git/git"], + ["/srv/git/project.git", undefined], + ["../other", undefined], + ]; + for (const [remote, page] of cases) assertEquals(remotePage(remote), page, remote); + assertEquals(commitUrl("https://github.com/a/b", "abc"), "https://github.com/a/b/commit/abc"); + assertEquals(commitUrl("https://gitlab.com/a/b", "abc"), "https://gitlab.com/a/b/-/commit/abc"); + assertEquals(commitUrl("https://bitbucket.org/a/b", "abc"), "https://bitbucket.org/a/b/commits/abc"); + assertEquals(branchUrl("https://codeberg.org/a/b", "fix/x"), "https://codeberg.org/a/b/src/branch/fix/x"); + assertEquals(branchUrl("https://github.com/a/b", "feat/ü"), "https://github.com/a/b/tree/feat/%C3%BC"); +}); + +Deno.test("folders and the find command", () => { + assertEquals(parseFolders("~/Projects, ~/work/:/opt/src\n~/Projects", "/home/ada"), [ + "/home/ada/Projects", + "/home/ada/work", + "/opt/src", + ]); + assertEquals(parseFolders("", "/home/ada").slice(0, 3), [ + "/home/ada/Projects", + "/home/ada/projects", + "/home/ada/code", + ]); + assertEquals(parseFolders("relative, ~", "/home/ada"), ["/home/ada"]); + const args = findArgs(["/home/ada/code"], 3); + assertEquals(args.slice(0, 5), ["/home/ada/code", "-mindepth", "1", "-maxdepth", "4"]); + assertEquals(args.slice(-6), ["-prune", "-o", "-name", ".git", "-print", "-prune"]); + assertEquals(findArgs(["/x"], 50)[4], "9"); + assertEquals( + reposFromFind( + "/home/ada/code/b/.git\n/home/ada/code/a/.git\n/home/ada/code/a/.git\nfind: ‘/home/ada/src’: No such file\n", + ), + ["/home/ada/code/a", "/home/ada/code/b"], + ); + assertEquals( + rootOf("/home/ada/code/org/api", ["/home/ada/code", "/home/ada/code/org", "/home/ada/src"]), + "/home/ada/code/org", + ); + assertEquals(rootOf("/elsewhere", ["/home/ada/code"]), undefined); + assertEquals([tilde("/home/ada/code", "/home/ada"), tilde("/home/adam", "/home/ada")], ["~/code", "/home/adam"]); +}); + +Deno.test("relative times", () => { + const now = 1_800_000_000_000; + const ago = (s: number) => relativeTime(now / 1000 - s, now); + assertEquals([ + ago(10), + ago(300), + ago(7200), + ago(90_000), + ago(4 * 86400), + ago(20 * 86400), + ago(90 * 86400), + ago(800 * 86400), + ], [ + "just now", + "5 min ago", + "2 h ago", + "yesterday", + "4 days ago", + "2 weeks ago", + "3 months ago", + "2 years ago", + ]); +}); + +Deno.test("branches and install hints", () => { + assertEquals(parseBranches("main\nfeature/x\n(HEAD detached at d787b58)\n\n"), ["main", "feature/x"]); + assertEquals(installCommand("ID=fedora\nVERSION_ID=41"), "sudo dnf install git"); + assertEquals(installCommand('ID=pop\nID_LIKE="ubuntu debian"'), "sudo apt install git"); + assertEquals(installCommand("ID=endeavouros\nID_LIKE=arch"), "sudo pacman -S git"); + assertEquals(installCommand('ID="opensuse-tumbleweed"'), "sudo zypper install git"); + assertEquals(installCommand(""), "Install git with your package manager"); +}); + +Deno.test("terminal command lines", () => { + assertEquals(terminalArgs("kitty", ["nvim", "."]), ["nvim", "."]); + assertEquals(terminalArgs("gnome-terminal", ["nvim", "."]), ["--", "nvim", "."]); + assertEquals(terminalArgs("tilix", ["nvim", "my file"]), ["-e", "nvim 'my file'"]); + assertEquals(shellLine(["git", "log", "it's"]), "git log 'it'\\''s'"); + assertEquals(shellArgs("wezterm", "/r"), ["start", "--cwd", "/r"]); + assertEquals(shellArgs("kitty", "/r"), ["--directory", "/r"]); + assertEquals(shellArgs("konsole", "/r"), ["--workdir", "/r"]); + assertEquals(shellArgs("gnome-terminal", "/r"), ["--working-directory=/r"]); + assertEquals(shellArgs("alacritty", "/r"), []); +}); diff --git a/extensions/git-repos/terminal.ts b/extensions/git-repos/terminal.ts new file mode 100644 index 0000000..9204974 --- /dev/null +++ b/extensions/git-repos/terminal.ts @@ -0,0 +1,110 @@ +// Running a command in the user's terminal emulator (copied from the SSH Hosts extension, plus a working directory). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** The arguments that open `terminal` on a shell in `dir` (most terminals start where they are launched). */ +export function shellArgs(terminal: string, dir: string): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--cwd", dir]; + case "kitty": + return ["--directory", dir]; + case "ptyxis": + return ["--new-window", "--working-directory", dir]; + case "kgx": + case "gnome-terminal": + case "xfce4-terminal": + case "tilix": + return [`--working-directory=${dir}`]; + case "konsole": + return ["--workdir", dir]; + case "foot": + return [`--working-directory=${dir}`]; + default: // alacritty, ghostty, xterm: the launch directory + return []; + } +} + +/** Opens a shell in `dir` in a new terminal window; returns the terminal used. */ +export function openTerminalAt(dir: string, preferred?: string): string { + return spawnFirst(preferred, (term) => shellArgs(term, dir), dir); +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string, cwd?: string): string { + return spawnFirst(preferred, (term) => terminalArgs(term, argv), cwd); +} + +function spawnFirst(preferred: string | undefined, args: (term: string) => string[], cwd?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: args(term), + cwd, + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/github/github_test.ts b/extensions/github/github_test.ts index e450e05..d296ea3 100644 --- a/extensions/github/github_test.ts +++ b/extensions/github/github_test.ts @@ -14,7 +14,8 @@ const pr: PullRequest = { isDraft: false, state: "OPEN", createdAt: "2026-10-27T08:00:00Z", - updatedAt: "2026-10-27T10:00:00Z", + // Relative to now: the item shows how long ago the PR changed. + updatedAt: new Date().toISOString(), additions: 86, deletions: 12, changedFiles: 4, diff --git a/extensions/google-cloud/backslash.toml b/extensions/google-cloud/backslash.toml new file mode 100644 index 0000000..3e5f55b --- /dev/null +++ b/extensions/google-cloud/backslash.toml @@ -0,0 +1,53 @@ +[extension] +id = "google-cloud" +title = "Google Cloud" +description = "Switch gcloud projects, SSH into Compute Engine VMs and follow Cloud Run services, their URLs and logs" +version = "1.0.0" +author = "Backslash" +icon = "bs-cloud" +license = "MIT" + +# gcloud does the authentication; the distribution is read for install hints. +[permissions] +read = ["/etc/os-release"] +run = [ + "gcloud", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "projects" +title = "Google Cloud Projects" +description = "Your projects, the current one first: switch with gcloud config set project" +icon = "bs-project" +keywords = ["gcloud", "gcp", "google cloud", "project", "switch", "projet", "changer"] + +[[commands]] +name = "instances" +title = "Compute Engine Instances" +description = "VMs of the current project: SSH, logs, serial console, start and stop" +icon = "bs-server" +keywords = ["gcloud", "gcp", "compute", "gce", "vm", "instance", "ssh", "serveur", "machine virtuelle"] + +[[commands]] +name = "run" +title = "Cloud Run Services" +description = "Services with their status, URL and revision: open, copy, logs" +icon = "bs-rocket" +keywords = ["gcloud", "gcp", "cloud run", "serverless", "service", "container", "deploy", "conteneur", "déploiement"] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "For SSH, logs and sign-in. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/google-cloud/commands/instances.ts b/extensions/google-cloud/commands/instances.ts new file mode 100644 index 0000000..db2fe78 --- /dev/null +++ b/extensions/google-cloud/commands/instances.ts @@ -0,0 +1,139 @@ +import { type Action, action, copy, exec, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { + age, + formatDate, + HEALTH_COLORS, + type Instance, + instanceConsoleUrl, + instanceHealth, + instanceLogsCommand, + parseInstances, + serialCommand, + sortInstances, + sshCommand, + statusLabel, + zoneRegion, +} from "../lib.ts"; +import { gcloud, guarded, inTerminal, type Prefs, projectActions, showProject } from "../gcp.ts"; +import { shellLine } from "../terminal.ts"; + +const SECTIONS = { ok: "Running", busy: "Changing", off: "Stopped", failed: "Problems" }; + +function power(i: Instance, project: string, verb: "start" | "stop"): Action { + return action(verb === "start" ? "Start Instance" : "Stop Instance", async () => { + await toast(`${verb === "start" ? "Starting" : "Stopping"} ${i.name}…`); + await exec("gcloud", ["compute", "instances", verb, i.name, `--zone=${i.zone}`, `--project=${project}`, "--async"]); + await toast(`${verb === "start" ? "Starting" : "Stopping"} ${i.name}`, { style: "success" }); + return refresh(); + }, { + icon: verb === "start" ? Icons.play : Icons.stop, + destructive: verb === "stop", + confirm: verb === "stop" ? `Stop ${i.name} in ${i.zone}?` : undefined, + shortcut: verb === "start" ? "s" : "x", + section: "Instance", + }); +} + +function item(i: Instance, project: string, prefs: Prefs): Item { + const health = instanceHealth(i.status); + const color = HEALTH_COLORS[health]; + const running = i.status === "RUNNING"; + const labels = Object.entries(i.labels).map(([k, v]) => `${k}=${v}`); + const actions: Action[] = []; + if (running) actions.push(inTerminal("SSH in Terminal", sshCommand(i, project), prefs, { section: "Terminal" })); + actions.push( + open(instanceConsoleUrl(i, project), "Open in Console", { icon: Icons.globe, shortcut: "o" }), + inTerminal("Logs in Terminal", instanceLogsCommand(i, project), prefs, { + icon: Icons.text, + shortcut: "l", + hold: true, + }), + ...(running + ? [inTerminal("Follow Serial Console", serialCommand(i, project), prefs, { icon: Icons.monitor })] + : []), + copy(i.name, "Copy Name", { shortcut: "period", section: "Copy" }), + ...(i.externalIp ? [copy(i.externalIp, "Copy External IP", { icon: Icons.globe, section: "Copy" })] : []), + ...(i.internalIp ? [copy(i.internalIp, "Copy Internal IP", { icon: Icons.server, section: "Copy" })] : []), + copy(shellLine(sshCommand(i, project)), "Copy SSH Command", { icon: Icons.terminal, section: "Copy" }), + copy(instanceConsoleUrl(i, project), "Copy Console URL", { icon: Icons.link, section: "Copy" }), + ); + if (i.status === "TERMINATED" || i.status === "STOPPED") actions.push(power(i, project, "start")); + if (running) actions.push(power(i, project, "stop")); + actions.push(action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), ...projectActions()); + + return { + id: `${i.zone}/${i.name}`, + title: i.name, + subtitle: i.zone, + icon: { source: Icons.server, tint: color }, + section: SECTIONS[health], + keywords: [ + i.status, + i.machineType, + i.internalIp ?? "", + i.externalIp ?? "", + zoneRegion(i.zone), + ...labels, + ...i.tags, + ], + accessories: [ + ...(i.spot ? [{ tag: "Spot", color: "purple" as const }] : []), + ...(i.externalIp ? [{ icon: Icons.globe, text: i.externalIp, tooltip: "External IP" }] : []), + { text: i.machineType, tooltip: "Machine type" }, + { tag: statusLabel(i.status), color }, + ], + detail: { + stats: [ + { label: "status", value: statusLabel(i.status), color }, + { label: "type", value: i.machineType }, + { label: "age", value: age(i.created) || "—" }, + ], + metadata: [ + meta.text("Zone", i.zone, { icon: Icons.pin }), + ...(i.externalIp ? [meta.text("External IP", i.externalIp, { icon: Icons.globe })] : []), + ...(i.internalIp ? [meta.text("Internal IP", i.internalIp, { icon: Icons.server })] : []), + ...(i.network ? [meta.text("Network", i.network, { icon: Icons.layers })] : []), + ...(i.os ? [meta.text("Image", i.os, { icon: Icons.box })] : []), + ...(i.cpuPlatform ? [meta.text("CPU", i.cpuPlatform, { icon: Icons.cpu })] : []), + ...(i.spot ? [meta.text("Provisioning", "Spot", { icon: Icons.bolt, color: "purple" })] : []), + ...(labels.length ? [meta.tags("Labels", labels)] : []), + ...(i.tags.length ? [meta.tags("Network tags", i.tags)] : []), + meta.separator(), + ...(i.created ? [meta.text("Created", formatDate(i.created), { icon: Icons.calendar })] : []), + meta.text("Project", project, { icon: Icons.project }), + ], + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter instances by name, zone, IP, label…", + filter: { + options: [ + { value: "running", title: "Running" }, + { value: "stopped", title: "Stopped" }, + { value: "all", title: "All" }, + ], + default: "all", + }, + empty: { title: "No instances", description: "No Compute Engine VM matches in this project", icon: Icons.server }, + status: () => summary, + actions: projectActions(), + items: ({ preferences, signal, filter }) => { + const prefs = preferences as Prefs; + summary = ""; + return guarded(prefs, signal, async (c) => { + const all = parseInstances(await gcloud(["compute", "instances", "list", `--project=${c.project}`], signal)); + const running = all.filter((i) => i.status === "RUNNING").length; + summary = `${c.project} · ${all.length} instance${all.length === 1 ? "" : "s"} · ${running} running`; + await showProject(c, `${running} running`); + const shown = all.filter((i) => + filter === "running" ? i.status === "RUNNING" : filter === "stopped" ? instanceHealth(i.status) === "off" : true + ); + return sortInstances(shown).map((i) => item(i, c.project, prefs)); + }); + }, +}); diff --git a/extensions/google-cloud/commands/projects.ts b/extensions/google-cloud/commands/projects.ts new file mode 100644 index 0000000..094d917 --- /dev/null +++ b/extensions/google-cloud/commands/projects.ts @@ -0,0 +1,73 @@ +import { action, copy, Icons, type Item, list, meta, open, refresh } from "@backslash/api"; +import { formatDate, parseProjects, type Project, projectConsoleUrl } from "../lib.ts"; +import { errorItem, gcloud, guarded, type Prefs, showProject, useProject } from "../gcp.ts"; + +function item(p: Project, current: boolean, account?: string): Item { + const active = p.state === "ACTIVE"; + const labels = Object.entries(p.labels).map(([k, v]) => `${k}=${v}`); + return { + id: p.id, + title: p.name, + subtitle: p.id === p.name ? undefined : p.id, + icon: { + source: current ? Icons.check : Icons.project, + tint: current ? "green" : active ? "blue" : "gray", + }, + section: current ? "Current Project" : active ? "Projects" : "Pending Deletion", + keywords: [p.id, p.number, ...labels], + accessories: [ + ...(active ? [] : [{ tag: "Deleting", color: "red" as const }]), + ...(current ? [{ tag: "Current", color: "green" as const }] : []), + ], + detail: { + metadata: [ + meta.text("Project ID", p.id, { icon: Icons.hash }), + meta.text("Number", p.number, { icon: Icons.hash }), + meta.text("State", active ? "Active" : p.state.replace(/_/g, " ").toLowerCase(), { + icon: Icons.dot, + color: active ? "green" : "red", + }), + ...(p.parent ? [meta.text("Parent", p.parent, { icon: Icons.layers })] : []), + ...(labels.length ? [meta.tags("Labels", labels)] : []), + meta.separator(), + ...(p.created ? [meta.text("Created", formatDate(p.created), { icon: Icons.calendar })] : []), + ...(current && account ? [meta.person("Account", account)] : []), + ], + }, + actions: [ + ...(current || !active ? [] : [action("Switch to Project", async () => { + await useProject(p.id); + return refresh(); + }, { icon: Icons.arrowRight })]), + open(projectConsoleUrl(p.id), "Open in Console", { icon: Icons.globe, shortcut: "o" }), + copy(p.id, "Copy Project ID", { shortcut: "period", section: "Copy" }), + copy(p.number, "Copy Project Number", { section: "Copy" }), + copy(`gcloud config set project ${p.id}`, "Copy Switch Command", { icon: Icons.terminal, section: "Copy" }), + ], + }; +} + +export default list({ + placeholder: "Switch Google Cloud project…", + empty: { title: "No projects", description: "This account cannot see any project", icon: Icons.project }, + status: (items) => { + const n = items.filter((i) => !["missing", "error", "no-project"].includes(i.id ?? "")).length; + return n ? `${n} project${n === 1 ? "" : "s"}` : ""; + }, + items: ({ preferences, signal }) => + guarded(preferences as Prefs, signal, async (c) => { + await showProject(c, c.account); + const projects = parseProjects(await gcloud(["projects", "list"], signal)); + return projects + .map((p) => item(p, p.id === c.project, c.account)) + .sort((a, b) => Number(b.section === "Current Project") - Number(a.section === "Current Project")); + }).then(async (items) => { + // Without a current project, the list of projects is exactly what is needed. + if (items[0]?.id !== "no-project") return items; + try { + return parseProjects(await gcloud(["projects", "list"], signal)).map((p) => item(p, false)); + } catch (err) { + return [errorItem(err, undefined, preferences as Prefs), ...items]; + } + }), +}); diff --git a/extensions/google-cloud/commands/run.ts b/extensions/google-cloud/commands/run.ts new file mode 100644 index 0000000..62ed3c2 --- /dev/null +++ b/extensions/google-cloud/commands/run.ts @@ -0,0 +1,130 @@ +import { + action, + copy, + detail, + type DetailSpec, + exec, + Icons, + type Item, + list, + meta, + open, + refresh, +} from "@backslash/api"; +import { + age, + formatDate, + HEALTH_COLORS, + parseServices, + readServiceLogsCommand, + type Service, + serviceConsoleUrl, + serviceHealth, + serviceLogsUrl, + shortImage, + tailServiceCommand, +} from "../lib.ts"; +import { gcloud, guarded, inTerminal, type Prefs, projectActions, showProject } from "../gcp.ts"; +import { shellLine } from "../terminal.ts"; + +const SECTIONS = { ok: "Ready", busy: "Deploying", failed: "Failing", off: "Off" }; +const LABELS = { ok: "Ready", busy: "Deploying", failed: "Failed", off: "Off" }; + +const fence = (text: string) => `\`\`\`\n${text.replace(/```/g, "ʼʼʼ") || "(no recent logs)"}\n\`\`\``; + +async function recentLogs(s: Service, project: string, prefs: Prefs): Promise { + const argv = readServiceLogsCommand(s, project); + const text = await exec(argv[0], argv.slice(1)).catch((e) => String(e.message)); + return detail({ + title: `${s.name} · logs`, + markdown: fence(text), + status: `Last ${Math.min(text.split("\n").length, 100)} entries · ${s.region}`, + actions: [ + inTerminal("Tail in Terminal", tailServiceCommand(s, project), prefs, { shortcut: "t" }), + copy(text, "Copy Logs", { shortcut: "c" }), + action("Reload", () => recentLogs(s, project, prefs), { icon: Icons.refresh, shortcut: "r" }), + open(serviceLogsUrl(s, project), "Open Logs in Console", { icon: Icons.globe }), + ], + }); +} + +function item(s: Service, project: string, prefs: Prefs): Item { + const health = serviceHealth(s); + const color = HEALTH_COLORS[health]; + const split = s.traffic.filter((t) => t.percent > 0); + return { + id: `${s.region}/${s.name}`, + title: s.name, + subtitle: s.url?.replace(/^https:\/\//, ""), + icon: { source: Icons.rocket, tint: color }, + section: SECTIONS[health], + keywords: [s.region, s.revision ?? "", s.image ?? "", s.modifier ?? ""], + accessories: [ + ...(split.length > 1 + ? [{ icon: Icons.shuffle, text: `${split.length} revisions`, tooltip: "Traffic split" }] + : []), + { icon: Icons.globe, text: s.region, tooltip: "Region" }, + ...(s.updated ? [{ text: age(s.updated), tooltip: `Updated ${formatDate(s.updated)}` }] : []), + { tag: LABELS[health], color }, + ], + detail: { + markdown: health === "failed" && s.message ? `**${s.reason ?? "Not ready"}**: ${s.message}` : undefined, + metadata: [ + meta.text("Status", LABELS[health], { icon: Icons.dot, color }), + ...(s.url ? [meta.link("URL", s.url.replace(/^https:\/\//, ""), s.url)] : []), + ...(s.revision ? [meta.text("Revision", s.revision, { icon: Icons.commit })] : []), + ...(s.image ? [meta.text("Image", shortImage(s.image), { icon: Icons.box })] : []), + ...(split.length + ? [meta.tags("Traffic", split.map((t) => `${t.tag ? `${t.tag}: ` : ""}${t.revision} ${t.percent}%`))] + : []), + ...(s.ingress ? [meta.text("Ingress", s.ingress, { icon: Icons.shield })] : []), + meta.separator(), + meta.text("Region", s.region, { icon: Icons.globe }), + ...(s.modifier ? [meta.person("Last deployed by", s.modifier)] : []), + ...(s.updated ? [meta.text("Updated", formatDate(s.updated), { icon: Icons.calendar })] : []), + meta.text("Project", project, { icon: Icons.project }), + ], + }, + actions: [ + ...(s.url ? [open(s.url, "Open Service URL", { icon: Icons.globe })] : []), + open(serviceConsoleUrl(s, project), "Open in Console", { icon: Icons.external, shortcut: "o" }), + action("Show Recent Logs", () => recentLogs(s, project, prefs), { + icon: Icons.text, + shortcut: "l", + section: "Logs", + }), + inTerminal("Tail Logs in Terminal", tailServiceCommand(s, project), prefs, { section: "Logs" }), + ...(s.url ? [copy(s.url, "Copy URL", { shortcut: "c", section: "Copy" })] : []), + copy(s.name, "Copy Service Name", { shortcut: "period", section: "Copy" }), + ...(s.image ? [copy(s.image, "Copy Image", { icon: Icons.box, section: "Copy" })] : []), + copy(shellLine(tailServiceCommand(s, project)), "Copy Logs Command", { icon: Icons.terminal, section: "Copy" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...projectActions(), + ], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter Cloud Run services by name, region, image…", + empty: { title: "No Cloud Run services", description: "Nothing deployed in this project", icon: Icons.rocket }, + status: () => summary, + actions: projectActions(), + items: ({ preferences, signal }) => { + const prefs = preferences as Prefs; + summary = ""; + return guarded(prefs, signal, async (c) => { + const services = parseServices(await gcloud(["run", "services", "list", `--project=${c.project}`], signal)); + const failing = services.filter((s) => serviceHealth(s) === "failed").length; + summary = `${c.project} · ${services.length} service${services.length === 1 ? "" : "s"}${ + failing ? ` · ${failing} failing` : "" + }`; + await showProject(c, failing ? `${failing} failing` : `${services.length} services`); + const order = ["failed", "busy", "ok", "off"]; + return services + .sort((a, b) => order.indexOf(serviceHealth(a)) - order.indexOf(serviceHealth(b))) + .map((s) => item(s, c.project, prefs)); + }); + }, +}); diff --git a/extensions/google-cloud/gcp.ts b/extensions/google-cloud/gcp.ts new file mode 100644 index 0000000..fed1141 --- /dev/null +++ b/extensions/google-cloud/gcp.ts @@ -0,0 +1,164 @@ +// Calling gcloud, and the items the commands share (missing CLI, sign-in, +// errors, project switching). + +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + open, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { classifyError, type GcloudConfig, installCommand, osFamily, parseConfig, parseProjects } from "./lib.ts"; +import { holdOpen, runInTerminal } from "./terminal.ts"; + +export interface Prefs { + terminal?: string; +} + +/** gcloud with JSON output, never prompting. */ +export function gcloud(args: string[], signal?: AbortSignal): Promise { + return exec("gcloud", [...args, "--format=json", "--quiet"], { signal }); +} + +export const isMissing = (err: unknown) => err instanceof Deno.errors.NotFound; + +export async function config(signal?: AbortSignal): Promise { + return parseConfig(await gcloud(["config", "list"], signal)); +} + +export async function useProject(id: string) { + await exec("gcloud", ["config", "set", "project", id, "--quiet"]); + await toast(`Switched to ${id}`, { style: "success", message: "gcloud config set project" }); +} + +export async function showProject(c: GcloudConfig, extra?: string) { + await updateCommandMetadata({ subtitle: c.project ? [c.project, extra].filter(Boolean).join(" · ") : null }) + .catch(() => {}); +} + +export function inTerminal( + title: string, + argv: string[], + prefs: Prefs, + o: { icon?: string; shortcut?: string; section?: string; hold?: boolean } = {}, +): Action { + return action(title, () => { + const term = runInTerminal(o.hold ? holdOpen(argv) : argv, prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: o.icon ?? Icons.terminal, shortcut: o.shortcut, section: o.section ?? "Terminal" }); +} + +/** "Switch Project ›", loaded when opened. */ +export function projectActions(): Action[] { + return [ + submenu("Switch Project", async () => { + const [c, projects] = await Promise.all([config(), gcloud(["projects", "list"]).then(parseProjects)]); + return projects.map((p) => + action(`${p.name} (${p.id})`, async () => { + await useProject(p.id); + return refresh(); + }, { icon: p.id === c.project ? Icons.check : Icons.project }) + ); + }, { icon: Icons.project, shortcut: "p", section: "Project" }), + ]; +} + +let hint: Promise | undefined; +const installHint = () => + hint ??= Deno.readTextFile("/etc/os-release").catch(() => "").then((t) => installCommand(osFamily(t))); + +export async function gcloudMissing(): Promise { + const cmd = await installHint(); + return { + id: "missing", + title: "The Google Cloud CLI is not installed", + subtitle: cmd, + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "This extension runs **gcloud** with your active configuration.", + "", + "```sh", + cmd, + "gcloud init", + "```", + ].join("\n"), + }, + actions: [ + copy(cmd.replace(/\s+#.*$/, ""), "Copy Install Command", { icon: Icons.terminal }), + open("https://cloud.google.com/sdk/docs/install", "Open Install Guide"), + ], + }; +} + +export function noProject(prefs: Prefs): Item { + return { + id: "no-project", + title: "No project selected", + subtitle: "Pick one with Switch Project (Ctrl+Shift+P)", + icon: { source: Icons.project, tint: "yellow" }, + detail: { markdown: "gcloud has no current project. Choose one, or run `gcloud init`." }, + actions: [ + ...projectActions(), + inTerminal("Run gcloud init", ["gcloud", "init"], prefs, { section: "Setup" }), + ], + }; +} + +/** An error as an item, with the way out. */ +export function errorItem(err: unknown, c: GcloudConfig | undefined, prefs: Prefs): Item { + const e = classifyError(err); + const fixes: Action[] = []; + if (e.problem === "auth") { + fixes.push(inTerminal("Sign In with gcloud auth login", ["gcloud", "auth", "login"], prefs, { + icon: Icons.key, + section: "Fix", + hold: true, + })); + } + if (e.problem === "api" && c?.project) { + fixes.push(open( + `https://console.cloud.google.com/apis/library/${e.api ?? ""}?project=${c.project}`, + "Enable the API", + { icon: Icons.plug, section: "Fix" }, + )); + } + return { + id: "error", + title: e.title, + subtitle: [c?.project, c?.account].filter(Boolean).join(" · "), + icon: { source: Icons.alert, tint: e.problem === "denied" || e.problem === "api" ? "orange" : "red" }, + detail: { markdown: `\`\`\`\n${e.message.replace(/```/g, "ʼʼʼ")}\n\`\`\`` }, + actions: [ + ...fixes, + action("Retry", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ...projectActions(), + copy(e.message, "Copy Error", { section: "Copy" }), + ], + }; +} + +/** Loads the config, then the project's items; problems become a single helpful item. */ +export async function guarded( + prefs: Prefs, + signal: AbortSignal, + load: (c: GcloudConfig & { project: string }) => Promise, +): Promise { + let c: GcloudConfig | undefined; + try { + c = await config(signal); + if (!c.project) return [noProject(prefs)]; + return await load(c as GcloudConfig & { project: string }); + } catch (err) { + if (isMissing(err)) return [await gcloudMissing()]; + if (signal.aborted) throw err; + return [errorItem(err, c, prefs)]; + } +} diff --git a/extensions/google-cloud/lib.ts b/extensions/google-cloud/lib.ts new file mode 100644 index 0000000..dbf9a4e --- /dev/null +++ b/extensions/google-cloud/lib.ts @@ -0,0 +1,336 @@ +// Pure logic of the Google Cloud extension: gcloud JSON mapping, console +// URLs, command lines, error classification. No I/O here. + +/** `https://…/zones/europe-west1-b` → `europe-west1-b`. */ +export const lastSegment = (url: string | undefined) => (url ?? "").split("/").pop() ?? ""; + +// --- Configuration and projects -------------------------------------------------------- + +export interface GcloudConfig { + project?: string; + account?: string; + region?: string; + zone?: string; +} + +export function parseConfig(json: string): GcloudConfig { + const c = JSON.parse(json || "{}") as { + core?: { project?: string; account?: string }; + compute?: { region?: string; zone?: string }; + }; + return { + project: c.core?.project || undefined, + account: c.core?.account || undefined, + region: c.compute?.region || undefined, + zone: c.compute?.zone || undefined, + }; +} + +export interface Project { + id: string; + name: string; + number: string; + state: string; + created?: Date; + parent?: string; + labels: Record; +} + +export function parseProjects(json: string): Project[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((p) => ({ + id: p.projectId, + name: p.name ?? p.projectId, + number: String(p.projectNumber ?? ""), + state: p.lifecycleState ?? p.state ?? "ACTIVE", + created: p.createTime ? new Date(p.createTime) : undefined, + parent: p.parent ? `${p.parent.type} ${p.parent.id}` : undefined, + labels: p.labels ?? {}, + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +// --- Compute Engine ------------------------------------------------------------------------ + +export interface Instance { + name: string; + id: string; + zone: string; + status: string; + machineType: string; + internalIp?: string; + externalIp?: string; + network?: string; + created?: Date; + labels: Record; + tags: string[]; + spot: boolean; + os?: string; + cpuPlatform?: string; +} + +export function parseInstances(json: string): Instance[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((i) => { + const nic = i.networkInterfaces?.[0]; + const boot = (i.disks ?? []).find((d: any) => d.boot); + return { + name: i.name, + id: String(i.id ?? ""), + zone: lastSegment(i.zone), + status: i.status ?? "UNKNOWN", + machineType: lastSegment(i.machineType), + internalIp: nic?.networkIP || undefined, + externalIp: nic?.accessConfigs?.find((a: any) => a.natIP)?.natIP || undefined, + network: nic?.network ? lastSegment(nic.network) : undefined, + created: i.creationTimestamp ? new Date(i.creationTimestamp) : undefined, + labels: i.labels ?? {}, + tags: i.tags?.items ?? [], + spot: i.scheduling?.provisioningModel === "SPOT" || i.scheduling?.preemptible === true, + os: boot?.licenses?.length ? lastSegment(boot.licenses[0]) : undefined, + cpuPlatform: i.cpuPlatform || undefined, + }; + }); +} + +/** `europe-west1-b` → `europe-west1`. */ +export const zoneRegion = (zone: string) => zone.replace(/-[a-z]$/, ""); + +export type Health = "ok" | "busy" | "off" | "failed"; + +export function instanceHealth(status: string): Health { + switch (status) { + case "RUNNING": + return "ok"; + case "PROVISIONING": + case "STAGING": + case "STOPPING": + case "SUSPENDING": + case "REPAIRING": + return "busy"; + case "TERMINATED": + case "STOPPED": + case "SUSPENDED": + return "off"; + default: + return "failed"; + } +} + +export const HEALTH_COLORS = { ok: "green", busy: "yellow", off: "gray", failed: "red" } as const; + +/** `TERMINATED` → `Stopped` (what the console shows), others title-cased. */ +export function statusLabel(status: string): string { + if (status === "TERMINATED") return "Stopped"; + return status.charAt(0) + status.slice(1).toLowerCase(); +} + +export function sortInstances(all: Instance[]): Instance[] { + const order: Health[] = ["failed", "busy", "ok", "off"]; + return [...all].sort((a, b) => + order.indexOf(instanceHealth(a.status)) - order.indexOf(instanceHealth(b.status)) || a.name.localeCompare(b.name) + ); +} + +// --- Cloud Run ---------------------------------------------------------------------------- + +export interface Service { + name: string; + region: string; + url?: string; + ready: "True" | "False" | "Unknown"; + reason?: string; + message?: string; + revision?: string; + image?: string; + modifier?: string; + updated?: Date; + ingress?: string; + traffic: { revision: string; percent: number; tag?: string; latest?: boolean }[]; +} + +export function parseServices(json: string): Service[] { + const data = JSON.parse(json || "[]") as any[]; + return data.map((s) => { + const ready = (s.status?.conditions ?? []).find((c: any) => c.type === "Ready"); + const container = s.spec?.template?.spec?.containers?.[0]; + const meta = s.metadata ?? {}; + return { + name: meta.name, + region: meta.labels?.["cloud.googleapis.com/location"] ?? "", + url: s.status?.url || s.status?.address?.url || undefined, + ready: (ready?.status ?? "Unknown") as Service["ready"], + reason: ready?.reason || undefined, + message: ready?.message || undefined, + revision: s.status?.latestReadyRevisionName || s.status?.latestCreatedRevisionName || undefined, + image: container?.image, + modifier: meta.annotations?.["serving.knative.dev/lastModifier"] ?? + meta.annotations?.["serving.knative.dev/creator"], + updated: ready?.lastTransitionTime + ? new Date(ready.lastTransitionTime) + : meta.creationTimestamp + ? new Date(meta.creationTimestamp) + : undefined, + ingress: meta.annotations?.["run.googleapis.com/ingress"], + traffic: (s.status?.traffic ?? []).map((t: any) => ({ + revision: t.revisionName ?? (t.latestRevision ? "latest" : ""), + percent: t.percent ?? 0, + tag: t.tag, + latest: t.latestRevision, + })), + }; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +export function serviceHealth(s: Service): Health { + return s.ready === "True" ? "ok" : s.ready === "False" ? "failed" : "busy"; +} + +/** `europe-docker.pkg.dev/acme/apps/api@sha256:…` → `api@sha256:1a2b3c4`. */ +export function shortImage(image: string | undefined): string { + if (!image) return ""; + const last = image.split("/").pop()!; + const at = last.indexOf("@sha256:"); + return at > 0 ? `${last.slice(0, at)}@${last.slice(at + 8, at + 15)}` : last; +} + +// --- URLs and commands ------------------------------------------------------------------- + +const C = "https://console.cloud.google.com"; +export const projectConsoleUrl = (p: string) => `${C}/home/dashboard?project=${encodeURIComponent(p)}`; +export const instanceConsoleUrl = (i: Instance, p: string) => + `${C}/compute/instancesDetail/zones/${i.zone}/instances/${encodeURIComponent(i.name)}?project=${ + encodeURIComponent(p) + }`; +export const serviceConsoleUrl = (s: Service, p: string) => + `${C}/run/detail/${s.region}/${encodeURIComponent(s.name)}/metrics?project=${encodeURIComponent(p)}`; +export const serviceLogsUrl = (s: Service, p: string) => + `${C}/run/detail/${s.region}/${encodeURIComponent(s.name)}/logs?project=${encodeURIComponent(p)}`; + +export const sshCommand = ( + i: Instance, + p: string, +) => ["gcloud", "compute", "ssh", i.name, `--zone=${i.zone}`, `--project=${p}`]; + +export const serialCommand = (i: Instance, p: string) => [ + "gcloud", + "compute", + "instances", + "tail-serial-port-output", + i.name, + `--zone=${i.zone}`, + `--project=${p}`, +]; + +export const instanceLogsCommand = (i: Instance, p: string) => [ + "gcloud", + "logging", + "read", + `resource.type="gce_instance" AND resource.labels.instance_id="${i.id}"`, + "--freshness=1d", + "--limit=200", + "--order=asc", + "--format=value(timestamp,severity,textPayload,jsonPayload.message)", + `--project=${p}`, +]; + +export const tailServiceCommand = (s: Service, p: string) => [ + "gcloud", + "beta", + "run", + "services", + "logs", + "tail", + s.name, + `--region=${s.region}`, + `--project=${p}`, +]; + +export const readServiceLogsCommand = (s: Service, p: string, limit = 100) => [ + "gcloud", + "run", + "services", + "logs", + "read", + s.name, + `--region=${s.region}`, + `--project=${p}`, + `--limit=${limit}`, +]; + +// --- Errors ------------------------------------------------------------------------------ + +export type Problem = "auth" | "api" | "denied" | "project" | "network" | "other"; + +export function classifyError(err: unknown): { problem: Problem; title: string; message: string; api?: string } { + const raw = (err instanceof Error ? err.message : String(err)).replace(/^gcloud exited with \d+: /, "").trim(); + const message = raw.split("\n").filter((l) => !/^\s*(- '@type'|metadata:|method:|service:|reason:)/.test(l)).join( + "\n", + ).trim(); + const is = (re: RegExp) => re.test(raw); + if ( + is( + /You do not currently have an active account|gcloud auth login|UNAUTHENTICATED|Reauthentication|refresh token|invalid_grant/i, + ) + ) { + return { problem: "auth", title: "Sign in to Google Cloud", message }; + } + const api = + /(\w+\.googleapis\.com) (?:has not been used|is not enabled)|SERVICE_DISABLED|API \[([\w.]+)\] not enabled/.exec( + raw, + ); + if (api) return { problem: "api", title: "API not enabled for this project", message, api: api[1] ?? api[2] }; + if ( + is( + /required '?project'? property|project property is not set|The required property \[project\] is not currently set/i, + ) + ) { + return { problem: "project", title: "No project selected", message }; + } + if (is(/PERMISSION_DENIED|does not have .* permission|Required '[\w.]+' permission|403/)) { + return { problem: "denied", title: "Permission denied", message }; + } + if (is(/Unable to find|Failed to connect|Connection aborted|timed out|Name or service not known|TransportError/i)) { + return { problem: "network", title: "Cannot reach Google Cloud", message }; + } + return { problem: "other", title: "gcloud failed", message }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = Math.max(0, (now.getTime() - date.getTime()) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +export const formatDate = (d?: Date) => + d && !isNaN(d.getTime()) ? d.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }) : ""; + +// --- Installing the CLI -------------------------------------------------------------------- + +export type Family = "debian" | "fedora" | "arch" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +export function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "yay -S google-cloud-cli # AUR"; + case "fedora": + return "sudo dnf install google-cloud-cli # after adding Google's repository"; + default: + return "sudo snap install google-cloud-cli --classic"; + } +} diff --git a/extensions/google-cloud/lib_test.ts b/extensions/google-cloud/lib_test.ts new file mode 100644 index 0000000..de6bbf1 --- /dev/null +++ b/extensions/google-cloud/lib_test.ts @@ -0,0 +1,180 @@ +import { assertEquals } from "@std/assert"; +import { + age, + classifyError, + installCommand, + instanceConsoleUrl, + instanceHealth, + instanceLogsCommand, + lastSegment, + osFamily, + parseConfig, + parseInstances, + parseProjects, + parseServices, + serviceConsoleUrl, + serviceHealth, + shortImage, + sortInstances, + sshCommand, + statusLabel, + tailServiceCommand, + zoneRegion, +} from "./lib.ts"; +import { holdOpen, terminalArgs } from "./terminal.ts"; + +Deno.test("config", () => { + assertEquals( + parseConfig( + JSON.stringify({ core: { project: "acme-prod", account: "a@acme.io" }, compute: { zone: "europe-west1-b" } }), + ), + { project: "acme-prod", account: "a@acme.io", region: undefined, zone: "europe-west1-b" }, + ); + assertEquals(parseConfig("{}").project, undefined); +}); + +Deno.test("projects", () => { + const projects = parseProjects(JSON.stringify([ + { lifecycleState: "ACTIVE", name: "Zeta", projectId: "zeta-1", projectNumber: 2 }, + { + createTime: "2023-02-01T10:00:00.000Z", + lifecycleState: "ACTIVE", + name: "Acme Production", + parent: { id: "4455", type: "organization" }, + projectId: "acme-prod", + projectNumber: "123456789012", + labels: { env: "prod" }, + }, + ])); + assertEquals(projects.map((p) => p.id), ["acme-prod", "zeta-1"]); + assertEquals(projects[0].parent, "organization 4455"); + assertEquals(projects[1].number, "2"); +}); + +const Z = "https://www.googleapis.com/compute/v1/projects/acme-prod/zones/"; + +Deno.test("Compute Engine instances", () => { + const all = parseInstances(JSON.stringify([ + { + id: "8812345678901234567", + name: "web-1", + zone: `${Z}europe-west1-b`, + status: "RUNNING", + machineType: `${Z}europe-west1-b/machineTypes/e2-medium`, + networkInterfaces: [{ + networkIP: "10.132.0.2", + network: "https://www.googleapis.com/compute/v1/projects/acme-prod/global/networks/default", + accessConfigs: [{ name: "External NAT", natIP: "34.77.12.9" }], + }], + tags: { items: ["http-server"] }, + disks: [{ boot: true, licenses: ["https://x/licenses/debian-12-bookworm"] }], + }, + { id: "2", name: "batch", zone: `${Z}europe-west4-a`, status: "TERMINATED", scheduling: { preemptible: true } }, + { id: "3", name: "db", zone: `${Z}us-central1-a`, status: "STAGING" }, + ])); + assertEquals(all[0].zone, "europe-west1-b"); + assertEquals(all[0].machineType, "e2-medium"); + assertEquals(all[0].externalIp, "34.77.12.9"); + assertEquals(all[0].network, "default"); + assertEquals(all[0].os, "debian-12-bookworm"); + assertEquals(all[1].spot, true); + assertEquals(all[1].internalIp, undefined); + assertEquals(sortInstances(all).map((i) => i.name), ["db", "web-1", "batch"]); + assertEquals(instanceHealth("TERMINATED"), "off"); + assertEquals(instanceHealth("REPAIRING"), "busy"); + assertEquals(statusLabel("TERMINATED"), "Stopped"); + assertEquals(statusLabel("RUNNING"), "Running"); + assertEquals(zoneRegion("europe-west1-b"), "europe-west1"); + assertEquals(lastSegment(undefined), ""); + assertEquals(sshCommand(all[0], "acme-prod"), [ + "gcloud", + "compute", + "ssh", + "web-1", + "--zone=europe-west1-b", + "--project=acme-prod", + ]); + assertEquals( + instanceConsoleUrl(all[0], "acme-prod"), + "https://console.cloud.google.com/compute/instancesDetail/zones/europe-west1-b/instances/web-1?project=acme-prod", + ); + assertEquals( + instanceLogsCommand(all[0], "p")[3], + 'resource.type="gce_instance" AND resource.labels.instance_id="8812345678901234567"', + ); +}); + +Deno.test("Cloud Run services", () => { + const services = parseServices(JSON.stringify([ + { + metadata: { + name: "worker", + labels: { "cloud.googleapis.com/location": "europe-west1" }, + annotations: { "serving.knative.dev/lastModifier": "ci@acme.iam.gserviceaccount.com" }, + }, + spec: { + template: { spec: { containers: [{ image: "europe-docker.pkg.dev/acme/apps/worker@sha256:1a2b3c4d5e6f" }] } }, + }, + status: { + url: "https://worker-abc-ew.a.run.app", + latestReadyRevisionName: "worker-00042-xiz", + conditions: [{ + type: "Ready", + status: "False", + reason: "RevisionFailed", + message: "Container failed to start", + lastTransitionTime: "2026-10-27T15:00:00Z", + }], + traffic: [{ revisionName: "worker-00042-xiz", percent: 100, latestRevision: true }], + }, + }, + { metadata: { name: "api", labels: { "cloud.googleapis.com/location": "us-central1" } }, status: {} }, + ])); + assertEquals(services.map((s) => s.name), ["api", "worker"]); + const w = services[1]; + assertEquals(serviceHealth(w), "failed"); + assertEquals(serviceHealth(services[0]), "busy"); + assertEquals(w.reason, "RevisionFailed"); + assertEquals(w.modifier, "ci@acme.iam.gserviceaccount.com"); + assertEquals(w.traffic, [{ revision: "worker-00042-xiz", percent: 100, tag: undefined, latest: true }]); + assertEquals(shortImage(w.image), "worker@1a2b3c4"); + assertEquals(shortImage("gcr.io/acme/api:1.4"), "api:1.4"); + assertEquals( + serviceConsoleUrl(w, "acme"), + "https://console.cloud.google.com/run/detail/europe-west1/worker/metrics?project=acme", + ); + assertEquals(tailServiceCommand(w, "acme").slice(0, 6), ["gcloud", "beta", "run", "services", "logs", "tail"]); +}); + +Deno.test("errors", () => { + const c = (m: string) => classifyError(new Error(`gcloud exited with 1: ${m}`)); + assertEquals( + c("ERROR: (gcloud.projects.list) You do not currently have an active account selected.\nPlease run:\n\n $ gcloud auth login") + .problem, + "auth", + ); + assertEquals( + c("ERROR: (gcloud.projects.list) UNAUTHENTICATED: Request had invalid authentication credentials.\n- '@type': type.googleapis.com/google.rpc.ErrorInfo\n reason: CREDENTIALS_MISSING") + .message, + "ERROR: (gcloud.projects.list) UNAUTHENTICATED: Request had invalid authentication credentials.", + ); + const api = c( + "ERROR: (gcloud.compute.instances.list) Compute Engine API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/compute.googleapis.com/overview?project=123 then retry.\n compute.googleapis.com has not been used", + ); + assertEquals(api.problem, "api"); + assertEquals(api.api, "compute.googleapis.com"); + assertEquals( + c("ERROR: (gcloud.run.services.list) PERMISSION_DENIED: Permission 'run.services.list' denied").problem, + "denied", + ); + assertEquals(c("ERROR: The required property [project] is not currently set.").problem, "project"); + assertEquals(c("boom").problem, "other"); +}); + +Deno.test("formatting and install hints", () => { + assertEquals(age(new Date("2026-10-27T15:00:00Z"), new Date("2026-10-28T10:00:00Z")), "19h"); + assertEquals(installCommand(osFamily("ID=arch")), "yay -S google-cloud-cli # AUR"); + assertEquals(installCommand(osFamily("ID=ubuntu\nID_LIKE=debian")), "sudo snap install google-cloud-cli --classic"); + assertEquals(terminalArgs("foot", ["gcloud", "compute", "ssh", "x"]), ["gcloud", "compute", "ssh", "x"]); + assertEquals(holdOpen(["gcloud", "auth", "login"])[2].startsWith("gcloud auth login;"), true); +}); diff --git a/extensions/google-cloud/terminal.ts b/extensions/google-cloud/terminal.ts new file mode 100644 index 0000000..ec5b7d5 --- /dev/null +++ b/extensions/google-cloud/terminal.ts @@ -0,0 +1,89 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** + * Wraps a command that ends by itself (a log dump, a login) so the terminal + * stays open on its output until the user presses Enter. + */ +export function holdOpen(argv: string[]): string[] { + return ["sh", "-c", `${shellLine(argv)}; printf '\\n[Press Enter to close] '; read -r _`]; +} + +/** + * Starts `argv` in a new terminal window that outlives the command. `env` is + * added to the terminal's environment (secrets go there, never in `argv`). + */ +export function runInTerminal(argv: string[], preferred?: string, env?: Record): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + env, + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/http-client/backslash.toml b/extensions/http-client/backslash.toml new file mode 100644 index 0000000..16d74c3 --- /dev/null +++ b/extensions/http-client/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "http-client" +title = "HTTP Client" +description = "Send HTTP requests from the launcher and read the status, timing, headers and formatted body" +version = "1.0.0" +author = "Backslash" +icon = "bs-globe" +license = "MIT" + +[permissions] +# Any host: the whole point is to call the URL the user types (APIs, local +# servers, staging hosts), which can't be known in advance. +net = ["*"] + +[[commands]] +name = "request" +title = "Send HTTP Request" +description = "Type GET https://…, a URL or a curl command; saved requests and history included" +icon = "bs-globe" +keywords = [ + "http", + "https", + "request", + "api", + "rest", + "curl", + "get", + "post", + "fetch", + "postman", + "insomnia", + "requête", + "envoyer", +] + +[[commands]] +name = "saved" +title = "Saved HTTP Requests" +description = "Your saved requests with their method, URL, headers and body, and the recent history" +icon = "bs-bookmark" +keywords = ["http", "request", "saved", "history", "api", "postman", "requêtes enregistrées", "historique"] diff --git a/extensions/http-client/commands/request.ts b/extensions/http-client/commands/request.ts new file mode 100644 index 0000000..2847205 --- /dev/null +++ b/extensions/http-client/commands/request.ts @@ -0,0 +1,103 @@ +import { action, close, copy, Icons, type Item, list } from "@backslash/api"; +import { formatBytes, hasSecret, parseInput, type Request, shortUrl, toCurl, validate } from "../lib.ts"; +import { clearHistory, historyItem, loadHistory, loadSaved, requestForm, savedItem, sendAndShow } from "../views.ts"; + +const matches = (q: string, ...fields: string[]) => { + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const hay = fields.join(" ").toLowerCase(); + return words.every((w) => hay.includes(w)); +}; + +function requestItem(r: Request): Item { + let problem: string | undefined; + try { + validate(r); + } catch (e) { + problem = (e as Error).message; + } + if (problem) { + return { + id: "invalid", + title: problem, + subtitle: `${r.method} ${r.url}`, + icon: { source: Icons.alert, tint: "red" }, + section: "Request", + }; + } + return { + id: "request", + title: `${r.method} ${shortUrl(r.url)}`, + subtitle: r.body ? `with a ${formatBytes(new TextEncoder().encode(r.body).length)} body` : "Enter to send", + icon: { source: Icons.arrowRight, tint: "blue" }, + section: "Request", + accessories: r.headers.length ? [{ text: `${r.headers.length} header${r.headers.length > 1 ? "s" : ""}` }] : [], + detail: { + markdown: `\`\`\`sh\n${toCurl(r)}\n\`\`\`${r.body ? `\n\n**Body**\n\n\`\`\`\n${r.body}\n\`\`\`` : ""}`, + }, + actions: [ + action("Send Request", (ctx) => sendAndShow(r, ctx), { icon: Icons.arrowRight }), + action("Save Request…", () => requestForm({ request: r }), { icon: Icons.bookmark, shortcut: "s" }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + action("Edit Headers and Body…", () => requestForm({ request: r }), { icon: Icons.text, shortcut: "e" }), + ], + }; +} + +export default list({ + placeholder: "GET https://api.example.com/users, a URL, or paste a curl command…", + empty: { + title: "Type a request", + description: "A URL, METHOD URL [body], or a curl command. Saved requests and history show up here too.", + icon: Icons.globe, + }, + actions: [action("New Saved Request…", () => requestForm(), { icon: Icons.plus, shortcut: "n" })], + status: (items) => { + const saved = items.filter((i) => i.section === "Saved").length; + const history = items.filter((i) => i.section === "History").length; + return [saved && `${saved} saved`, history && `${history} in history`].filter(Boolean).join(" · ") || + "Responses open in a page with status, headers and body"; + }, + async search(query) { + const items: Item[] = []; + let parsed: Request | undefined; + try { + parsed = parseInput(query); + } catch (e) { + items.push({ + id: "invalid", + title: (e as Error).message, + subtitle: "Can't read this request", + icon: { source: Icons.alert, tint: "red" }, + section: "Request", + }); + } + if (parsed) items.push(requestItem(parsed)); + const filter = parsed ? "" : query; + const [saved, history] = await Promise.all([loadSaved(), loadHistory()]); + items.push( + ...saved.filter((s) => matches(filter, s.name, s.method, s.url)).map((s) => savedItem(s, "close")), + ...history.filter((h) => matches(filter, h.method, h.url, String(h.status))).map((h) => { + const item = historyItem(h, "close"); + item.actions = [ + ...item.actions!, + action("Clear History", async () => { + await clearHistory(); + return close(); + }, { + icon: Icons.trash, + destructive: true, + confirm: "Forget every request in the history?", + section: "Danger", + }), + ]; + return item; + }), + ); + return items; + }, +}); diff --git a/extensions/http-client/commands/saved.ts b/extensions/http-client/commands/saved.ts new file mode 100644 index 0000000..b33fc10 --- /dev/null +++ b/extensions/http-client/commands/saved.ts @@ -0,0 +1,27 @@ +import { action, Icons, list } from "@backslash/api"; +import { clearHistory, historyItem, live, loadHistory, loadSaved, requestForm, savedItem } from "../views.ts"; + +export default list({ + placeholder: "Filter saved requests and history…", + empty: { + title: "No saved requests", + description: "Press Ctrl+N to save one, or send a request from the Send HTTP Request command.", + icon: Icons.bookmark, + }, + actions: [ + action("New Saved Request…", () => requestForm({ after: "pop" }), { icon: Icons.plus, shortcut: "n" }), + action("Clear History", async () => { + await clearHistory(); + }, { icon: Icons.trash, destructive: true, confirm: "Forget every request in the history?", section: "Danger" }), + ], + status: (items) => { + const saved = items.filter((i) => i.section === "Saved").length; + return `${saved} saved · ${items.length - saved} in history`; + }, + // Reloads after a save, an edit or a deletion made from this list. + items: ({ signal }) => + live(async () => { + const [saved, history] = await Promise.all([loadSaved(), loadHistory()]); + return [...saved.map((s) => savedItem(s, "pop")), ...history.map((h) => historyItem(h, "pop"))]; + }, signal), +}); diff --git a/extensions/http-client/lib.ts b/extensions/http-client/lib.ts new file mode 100644 index 0000000..1776b2e --- /dev/null +++ b/extensions/http-client/lib.ts @@ -0,0 +1,404 @@ +// HTTP requests: parsing what the user types (or a pasted curl command), sending, formatting. Pure, tested in lib_test.ts. + +export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +export interface Request { + method: string; + url: string; + headers: [string, string][]; + body?: string; +} + +export interface SavedRequest extends Request { + id: string; + name: string; +} + +export interface HistoryEntry extends Request { + status: number; + ms: number; + /** Unix ms. */ + at: number; +} + +export interface Response { + status: number; + statusText: string; + ms: number; + size: number; + url: string; + redirected: boolean; + headers: [string, string][]; + contentType: string; + /** Undefined for binary bodies. */ + text?: string; +} + +/** Splits a command line like a POSIX shell: quotes, backslashes, line continuations. */ +export function shellWords(line: string): string[] { + const words: string[] = []; + let word = ""; + let inWord = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\" && line[i + 1] === "\n") { + i++; + } else if (/\s/.test(c)) { + if (inWord) words.push(word); + word = ""; + inWord = false; + } else if (c === "'") { + const end = line.indexOf("'", i + 1); + if (end < 0) throw new Error("Unterminated ' quote"); + word += line.slice(i + 1, end); + i = end; + inWord = true; + } else if (c === '"') { + let j = i + 1; + for (; j < line.length && line[j] !== '"'; j++) { + if (line[j] === "\\" && '"\\$`\n'.includes(line[j + 1])) j++; + word += line[j]; + } + if (j >= line.length) throw new Error('Unterminated " quote'); + i = j; + inWord = true; + } else if (c === "$" && line[i + 1] === "'") { + // $'…' with \n, \t and \' escapes. + let j = i + 2; + for (; j < line.length && line[j] !== "'"; j++) { + if (line[j] === "\\") { + const e = line[++j]; + word += ({ n: "\n", t: "\t", r: "\r", "'": "'", "\\": "\\" } as Record)[e] ?? `\\${e}`; + } else word += line[j]; + } + i = j; + inWord = true; + } else if (c === "\\") { + word += line[++i] ?? ""; + inWord = true; + } else { + word += c; + inWord = true; + } + } + if (inWord) words.push(word); + return words; +} + +/** `example.com/x` → `https://example.com/x`; `localhost:8000` → `http://localhost:8000`. */ +export function normalizeUrl(input: string): string { + const t = input.trim(); + if (/^https?:\/\//i.test(t)) return t; + if (/^(localhost|127\.|0\.0\.0\.0|\[::1\]|10\.|192\.168\.|[\w-]+\.local\b)/i.test(t)) return `http://${t}`; + return `https://${t}`; +} + +export function looksLikeUrl(word: string): boolean { + return /^https?:\/\//i.test(word) || + /^(localhost|[\w-]+(\.[\w-]+)+|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i.test(word); +} + +export function parseHeader(line: string): [string, string] | undefined { + const i = line.indexOf(":"); + if (i <= 0) return undefined; + return [line.slice(0, i).trim(), line.slice(i + 1).trim()]; +} + +/** `Key: value` lines of a text area. */ +export function parseHeaders(text: string): [string, string][] { + return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => { + const h = parseHeader(l); + if (!h) throw new Error(`“${l}” is not a header: write Name: value`); + return h; + }); +} + +export const headerText = (headers: [string, string][]) => headers.map(([k, v]) => `${k}: ${v}`).join("\n"); + +const header = (headers: [string, string][], name: string) => + headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1]; + +/** The usual curl flags; others are ignored when harmless, refused otherwise. */ +export function parseCurl(words: string[]): Request { + let method: string | undefined; + let url: string | undefined; + const headers: [string, string][] = []; + const data: string[] = []; + let head = false; + let get = false; + const IGNORED = new Set([ + "-L", + "--location", + "-s", + "--silent", + "-S", + "--show-error", + "-v", + "--verbose", + "-i", + "--include", + "-k", + "--insecure", + "--compressed", + "-f", + "--fail", + "-#", + "--http1.1", + "--http2", + ]); + const WITH_VALUE = new Set([ + "-o", + "--output", + "-m", + "--max-time", + "--connect-timeout", + "-w", + "--write-out", + "--retry", + ]); + // `-fsSL` is `-f -s -S -L`. + words = words.flatMap((w) => /^-[sSLkivfIG#]{2,}$/.test(w) ? [...w.slice(1)].map((c) => `-${c}`) : [w]); + for (let i = 1; i < words.length; i++) { + const w = words[i]; + const value = () => { + const v = words[++i]; + if (v === undefined) throw new Error(`${w} needs a value`); + return v; + }; + if (w === "-X" || w === "--request") method = value().toUpperCase(); + else if (w.startsWith("-X") && w.length > 2) method = w.slice(2).toUpperCase(); + else if (w === "-H" || w === "--header") { + const h = parseHeader(value()); + if (h) headers.push(h); + } else if (["-d", "--data", "--data-raw", "--data-binary", "--data-ascii"].includes(w)) { + const v = value(); + if (v.startsWith("@") && w !== "--data-raw") throw new Error("Bodies read from files (@file) aren't supported"); + data.push(v); + } else if (w === "--data-urlencode") { + const v = value(); + const eq = v.indexOf("="); + data.push(eq >= 0 ? `${v.slice(0, eq + 1)}${encodeURIComponent(v.slice(eq + 1))}` : encodeURIComponent(v)); + } else if (w === "--json") { + data.push(value()); + if (!header(headers, "content-type")) headers.push(["Content-Type", "application/json"]); + if (!header(headers, "accept")) headers.push(["Accept", "application/json"]); + } else if (w === "-u" || w === "--user") headers.push(["Authorization", `Basic ${btoa(value())}`]); + else if (w === "-A" || w === "--user-agent") headers.push(["User-Agent", value()]); + else if (w === "-e" || w === "--referer") headers.push(["Referer", value()]); + else if (w === "-b" || w === "--cookie") headers.push(["Cookie", value()]); + else if (w === "-I" || w === "--head") head = true; + else if (w === "-G" || w === "--get") get = true; + else if (w === "--url") url = value(); + else if (IGNORED.has(w)) continue; + else if (WITH_VALUE.has(w)) value(); + else if (w.startsWith("-")) throw new Error(`curl option ${w} isn't supported here`); + else url = w; + } + if (!url) throw new Error("No URL in this curl command"); + let body = data.length ? data.join("&") : undefined; + url = normalizeUrl(url); + if (get && body) { + url += (url.includes("?") ? "&" : "?") + body; + body = undefined; + } + if (body && !header(headers, "content-type")) headers.push(["Content-Type", "application/x-www-form-urlencoded"]); + return { method: method ?? (head ? "HEAD" : body ? "POST" : "GET"), url, headers, body }; +} + +/** + * What the user typed: `GET https://…`, `POST api.example.com/items {"a":1}`, + * a bare URL, or a pasted curl command. Undefined when it isn't a request. + */ +export function parseInput(input: string): Request | undefined { + const t = input.trim(); + if (!t) return undefined; + if (/^curl\s/.test(t)) return parseCurl(shellWords(t)); + const m = t.match(/^(?:([A-Za-z]+)\s+)?(\S+)(?:\s+([\s\S]*))?$/); + if (!m) return undefined; + const method = m[1]?.toUpperCase(); + if ((method && !METHODS.includes(method)) || !looksLikeUrl(m[2])) return undefined; + const body = m[3]?.trim() || undefined; + const headers: [string, string][] = []; + if (body && /^[[{]/.test(body)) headers.push(["Content-Type", "application/json"]); + return { method: method ?? (body ? "POST" : "GET"), url: normalizeUrl(m[2]), headers, body }; +} + +const quote = (s: string) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; + +/** A curl command doing the same request. */ +export function toCurl(r: Request): string { + const parts = ["curl"]; + if (r.method === "HEAD") parts.push("-I"); + else if (r.method !== "GET" && !(r.method === "POST" && r.body)) parts.push("-X", r.method); + parts.push(quote(r.url)); + for (const [k, v] of r.headers) parts.push("-H", quote(`${k}: ${v}`)); + if (r.body) parts.push("--data-raw", quote(r.body)); + return parts.join(" "); +} + +export function validate(r: Request): void { + if (!METHODS.includes(r.method)) throw new Error(`Unknown method ${r.method}: use ${METHODS.join(", ")}`); + let u: URL; + try { + u = new URL(r.url); + } catch { + throw new Error(`“${r.url}” is not a valid URL`); + } + if (!/^https?:$/.test(u.protocol)) throw new Error("Only http:// and https:// URLs can be sent"); + if (r.body && (r.method === "GET" || r.method === "HEAD")) { + throw new Error(`A ${r.method} request can't have a body: use POST, PUT or PATCH`); + } +} + +const TEXTUAL = + /^(text\/|application\/(json|.*\+json|xml|.*\+xml|javascript|ecmascript|x-www-form-urlencoded|yaml|toml|graphql|x-ndjson|problem\+json)|image\/svg\+xml)/i; + +export const isTextual = (contentType: string) => !contentType || TEXTUAL.test(contentType); + +/** Sends the request; `fetchFn` is replaceable for tests. */ +export async function send( + r: Request, + options: { signal?: AbortSignal; fetchFn?: typeof fetch; now?: () => number } = {}, +): Promise { + validate(r); + const now = options.now ?? (() => performance.now()); + const start = now(); + const res = await (options.fetchFn ?? fetch)(r.url, { + method: r.method, + headers: r.headers, + body: r.body, + signal: options.signal, + redirect: "follow", + }); + const bytes = new Uint8Array(await res.arrayBuffer()); + const ms = Math.round(now() - start); + const headers = [...res.headers.entries()] as [string, string][]; + const contentType = res.headers.get("content-type") ?? ""; + const charset = contentType.match(/charset=([\w-]+)/i)?.[1]; + let text: string | undefined; + if (isTextual(contentType)) { + try { + text = new TextDecoder(charset ?? "utf-8").decode(bytes); + } catch { + text = new TextDecoder().decode(bytes); + } + } + return { + status: res.status, + statusText: res.statusText, + ms, + size: bytes.byteLength, + url: res.url || r.url, + redirected: res.redirected, + headers, + contentType, + text, + }; +} + +/** The body for a code block: JSON indented, the rest as is. */ +export function prettyBody(contentType: string, text: string): { lang: string; text: string } { + const type = contentType.toLowerCase(); + const trimmed = text.trim(); + if (/json/.test(type) || (!type && /^[[{]/.test(trimmed))) { + try { + return { lang: "json", text: JSON.stringify(JSON.parse(trimmed), null, 2) }; + } catch { /* not JSON after all */ } + } + if (/x-ndjson/.test(type)) { + try { + return { + lang: "json", + text: trimmed.split("\n").map((l) => JSON.stringify(JSON.parse(l), null, 2)).join("\n"), + }; + } catch { /* keep raw */ } + } + if (/html/.test(type)) return { lang: "html", text }; + if (/xml/.test(type)) return { lang: "xml", text }; + if (/x-www-form-urlencoded/.test(type)) { + return { lang: "", text: [...new URLSearchParams(trimmed)].map(([k, v]) => `${k} = ${v}`).join("\n") }; + } + return { lang: "", text }; +} + +/** The title of an HTML page, if any. */ +export const htmlTitle = (html: string) => + html.match(/]*>([\s\S]*?)<\/title>/i)?.[1].replace(/\s+/g, " ").trim() || undefined; + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +export const formatMs = (ms: number) => ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(2)} s`; + +export type Tint = "green" | "blue" | "purple" | "orange" | "red" | "gray"; + +export function statusTint(status: number): Tint { + if (status >= 500) return "red"; + if (status >= 400) return "orange"; + if (status >= 300) return "purple"; + if (status >= 200) return "green"; + return "blue"; +} + +/** Standard reason phrases, for HTTP/2 responses that carry none. */ +const REASONS: Record = { + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 410: "Gone", + 415: "Unsupported Media Type", + 422: "Unprocessable Content", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +export const reason = (status: number, text: string) => text || REASONS[status] || ""; + +/** Keeps the newest entries, one per method and URL. */ +export function addHistory(history: HistoryEntry[], entry: HistoryEntry, max = 30): HistoryEntry[] { + return [entry, ...history.filter((h) => !(h.method === entry.method && h.url === entry.url))].slice(0, max); +} + +/** "api.example.com/v1/items" for a list subtitle. */ +export function shortUrl(url: string): string { + try { + const u = new URL(url); + return `${u.host}${u.pathname === "/" ? "" : u.pathname}${u.search}`; + } catch { + return url; + } +} + +/** "2 min ago", "3 h ago", "yesterday", "12 Oct". */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + if (s < 172800) return "yesterday"; + return new Date(ms).toLocaleDateString("en", { day: "numeric", month: "short" }); +} + +/** Headers that carry credentials: copies of the request are kept out of clipboard histories. */ +export const hasSecret = (r: Request) => + r.headers.some(([k]) => /^(authorization|proxy-authorization|cookie|x-api-key|x-auth-token|private-token)$/i.test(k)); diff --git a/extensions/http-client/lib_test.ts b/extensions/http-client/lib_test.ts new file mode 100644 index 0000000..8366f20 --- /dev/null +++ b/extensions/http-client/lib_test.ts @@ -0,0 +1,255 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { + addHistory, + ago, + formatBytes, + formatMs, + hasSecret, + type HistoryEntry, + htmlTitle, + isTextual, + looksLikeUrl, + normalizeUrl, + parseCurl, + parseHeaders, + parseInput, + prettyBody, + reason, + send, + shellWords, + shortUrl, + statusTint, + toCurl, + validate, +} from "./lib.ts"; + +Deno.test("what the user types", () => { + assertEquals(parseInput("https://api.github.com/zen"), { + method: "GET", + url: "https://api.github.com/zen", + headers: [], + body: undefined, + }); + assertEquals(parseInput("delete api.example.com/items/3")?.method, "DELETE"); + assertEquals(parseInput("localhost:8000/health")?.url, "http://localhost:8000/health"); + assertEquals(parseInput('POST api.example.com/items {"name": "Ada"}'), { + method: "POST", + url: "https://api.example.com/items", + headers: [["Content-Type", "application/json"]], + body: '{"name": "Ada"}', + }); + // A body without a method is a POST. + assertEquals(parseInput("api.example.com/form a=1&b=2")?.method, "POST"); + assertEquals(parseInput("192.168.1.10:8080")?.url, "http://192.168.1.10:8080"); + assertEquals([parseInput(""), parseInput("hello"), parseInput("FETCH https://x.com"), parseInput("what is http")], [ + undefined, + undefined, + undefined, + undefined, + ]); + assertEquals([looksLikeUrl("example.com"), looksLikeUrl("example"), looksLikeUrl("[::1]:3000/x")], [ + true, + false, + true, + ]); + assertEquals([normalizeUrl("printer.local/status"), normalizeUrl("HTTP://x.y")], [ + "http://printer.local/status", + "HTTP://x.y", + ]); +}); + +Deno.test("shell words", () => { + assertEquals(shellWords(`curl -H 'A: b c' -d "{\\"x\\": 1}" $'a\\nb' plain\\ word`), [ + "curl", + "-H", + "A: b c", + "-d", + '{"x": 1}', + "a\nb", + "plain word", + ]); + assertEquals(shellWords("curl \\\n https://x.com"), ["curl", "https://x.com"]); + assertEquals(shellWords("it''s \"\""), ["its", ""]); + assertThrows(() => shellWords("curl 'oops"), Error, "Unterminated"); +}); + +Deno.test("pasted curl commands", () => { + const r = parseInput( + `curl -fsSL -X PATCH 'https://api.example.com/users/1' -H 'Authorization: Bearer abc' -H "Accept: application/json" --data-raw '{"name":"Ada"}' --compressed`, + ); + assertEquals(r, { + method: "PATCH", + url: "https://api.example.com/users/1", + headers: [["Authorization", "Bearer abc"], ["Accept", "application/json"], [ + "Content-Type", + "application/x-www-form-urlencoded", + ]], + body: '{"name":"Ada"}', + }); + assertEquals(parseCurl(shellWords("curl -d a=1 -d b=2 example.com")), { + method: "POST", + url: "https://example.com", + headers: [["Content-Type", "application/x-www-form-urlencoded"]], + body: "a=1&b=2", + }); + assertEquals(parseCurl(shellWords("curl -G -d q=deno example.com/search")).url, "https://example.com/search?q=deno"); + assertEquals(parseCurl(shellWords("curl --json '{\"a\":1}' example.com")).headers, [ + ["Content-Type", "application/json"], + ["Accept", "application/json"], + ]); + assertEquals(parseCurl(shellWords("curl -u ada:secret example.com")).headers, [[ + "Authorization", + "Basic YWRhOnNlY3JldA==", + ]]); + assertEquals(parseCurl(shellWords("curl -I example.com")).method, "HEAD"); + assertEquals(parseCurl(shellWords("curl --data-urlencode 'q=a b' example.com")).body, "q=a%20b"); + assertEquals(parseCurl(shellWords("curl -o out.html -m 10 example.com")).url, "https://example.com"); + assertThrows(() => parseCurl(shellWords("curl --foo example.com")), Error, "--foo isn't supported"); + assertThrows(() => parseCurl(shellWords("curl -d @file.json example.com")), Error, "@file"); + assertThrows(() => parseCurl(shellWords("curl -s")), Error, "No URL"); +}); + +Deno.test("copy as curl reads back the same request", () => { + const r = { + method: "PUT", + url: "https://api.example.com/items/1?x=a b", + headers: [["Content-Type", "application/json"], ["X-Note", "it's"]] as [string, string][], + body: '{"name": "Ada"}', + }; + const line = toCurl(r); + assertEquals( + line, + `curl -X PUT 'https://api.example.com/items/1?x=a b' -H 'Content-Type: application/json' -H 'X-Note: it'\\''s' --data-raw '{"name": "Ada"}'`, + ); + assertEquals(parseCurl(shellWords(line)), r); + assertEquals(toCurl({ method: "GET", url: "https://x.com", headers: [] }), "curl https://x.com"); + assertEquals(toCurl({ method: "HEAD", url: "https://x.com", headers: [] }), "curl -I https://x.com"); + assertEquals( + toCurl({ method: "POST", url: "https://x.com", headers: [], body: "a" }), + "curl https://x.com --data-raw a", + ); +}); + +Deno.test("headers and validation", () => { + assertEquals(parseHeaders("Accept: application/json\n\n# note\nX-Url: https://a.b/c"), [ + ["Accept", "application/json"], + ["X-Url", "https://a.b/c"], + ]); + assertThrows(() => parseHeaders("no colon here"), Error, "is not a header"); + assertThrows( + () => validate({ method: "GET", url: "https://x.com", headers: [], body: "x" }), + Error, + "can't have a body", + ); + assertThrows(() => validate({ method: "GET", url: "ftp://x.com", headers: [] }), Error, "Only http"); + assertThrows(() => validate({ method: "GET", url: "nope", headers: [] }), Error, "not a valid URL"); + assertThrows(() => validate({ method: "BREW", url: "https://x.com", headers: [] }), Error, "Unknown method"); +}); + +Deno.test("send with a fake fetch", async () => { + let seen: RequestInit | undefined; + const fetchFn = ((_url: string, init: RequestInit) => { + seen = init; + return Promise.resolve( + new Response(JSON.stringify({ id: 1, name: "Ada" }), { + status: 201, + headers: { "content-type": "application/json; charset=utf-8", "x-request-id": "abc" }, + }), + ); + }) as unknown as typeof fetch; + let t = 1000; + const res = await send( + { + method: "POST", + url: "https://api.example.com/users", + headers: [["Content-Type", "application/json"]], + body: "{}", + }, + { fetchFn, now: () => (t += 42) }, + ); + assertEquals(seen?.method, "POST"); + assertEquals(seen?.body, "{}"); + assertEquals([res.status, res.ms, res.size, res.contentType, res.text], [ + 201, + 42, + 21, + "application/json; charset=utf-8", + '{"id":1,"name":"Ada"}', + ]); + assertEquals(res.headers.find(([k]) => k === "x-request-id"), ["x-request-id", "abc"]); + const binary = await send({ method: "GET", url: "https://x.com/a.png", headers: [] }, { + fetchFn: (() => + Promise.resolve( + new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/png" } }), + )) as unknown as typeof fetch, + }); + assertEquals([binary.text, binary.size], [undefined, 3]); + await assertRejects(() => send({ method: "GET", url: "ftp://x", headers: [] }, { fetchFn }), Error, "Only http"); +}); + +Deno.test("bodies are shown readably", () => { + assertEquals(prettyBody("application/json", '{"a":[1,2]}'), { + lang: "json", + text: '{\n "a": [\n 1,\n 2\n ]\n}', + }); + assertEquals(prettyBody("application/problem+json", '{"title":"x"}').lang, "json"); + assertEquals(prettyBody("", ' {"a":1}').text, '{\n "a": 1\n}'); + assertEquals(prettyBody("application/json", "not json"), { lang: "", text: "not json" }); + assertEquals(prettyBody("text/html", "

"), { lang: "html", text: "

" }); + assertEquals(prettyBody("application/x-www-form-urlencoded", "a=1&b=x%20y").text, "a = 1\nb = x y"); + assertEquals(prettyBody("application/x-ndjson", '{"a":1}\n{"b":2}').text, '{\n "a": 1\n}\n{\n "b": 2\n}'); + assertEquals(htmlTitle("\n Deno docs "), "Deno docs"); + assertEquals([ + isTextual("text/plain"), + isTextual("application/vnd.api+json"), + isTextual("image/svg+xml"), + isTextual("image/png"), + ], [ + true, + true, + true, + false, + ]); +}); + +Deno.test("formatting and history", () => { + assertEquals([formatBytes(512), formatBytes(2048), formatBytes(50_000), formatBytes(3_500_000)], [ + "512 B", + "2.0 KB", + "49 KB", + "3.3 MB", + ]); + assertEquals([formatMs(87), formatMs(1534)], ["87 ms", "1.53 s"]); + assertEquals([statusTint(101), statusTint(204), statusTint(304), statusTint(404), statusTint(503)], [ + "blue", + "green", + "purple", + "orange", + "red", + ]); + assertEquals([reason(404, ""), reason(200, "Fine"), reason(299, "")], ["Not Found", "Fine", ""]); + assertEquals(shortUrl("https://api.example.com/v1/items?page=2"), "api.example.com/v1/items?page=2"); + assertEquals(shortUrl("https://example.com/"), "example.com"); + const now = Date.UTC(2026, 9, 28, 12); + assertEquals( + [ago(now - 5_000, now), ago(now - 300_000, now), ago(now - 7_200_000, now), ago(now - 100_000_000, now)], + [ + "just now", + "5 min ago", + "2 h ago", + "yesterday", + ], + ); + const h = (url: string, at: number): HistoryEntry => ({ method: "GET", url, headers: [], status: 200, ms: 10, at }); + const list = addHistory([h("https://a", 1), h("https://b", 2)], h("https://a", 3), 2); + assertEquals(list.map((e) => [e.url, e.at]), [["https://a", 3], ["https://b", 2]]); + assertEquals(addHistory([h("https://a", 1), h("https://b", 2)], h("https://c", 3), 2).map((e) => e.url), [ + "https://c", + "https://a", + ]); +}); + +Deno.test("requests with credentials are flagged", () => { + assertEquals(hasSecret({ method: "GET", url: "https://x.com", headers: [["Authorization", "Bearer x"]] }), true); + assertEquals(hasSecret({ method: "GET", url: "https://x.com", headers: [["Accept", "*/*"]] }), false); +}); diff --git a/extensions/http-client/views.ts b/extensions/http-client/views.ts new file mode 100644 index 0000000..c737cba --- /dev/null +++ b/extensions/http-client/views.ts @@ -0,0 +1,309 @@ +// Views shared by the commands: the response page, the request form, saved and history items. + +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + open, + pop, + replace, + storage, + toast, +} from "@backslash/api"; +import { + addHistory, + ago, + formatBytes, + formatMs, + hasSecret, + headerText, + type HistoryEntry, + htmlTitle, + METHODS, + parseHeaders, + prettyBody, + reason, + type Request, + type Response, + type SavedRequest, + send, + shortUrl, + statusTint, + toCurl, + validate, +} from "./lib.ts"; + +type Json = Parameters[1]; + +// --- Storage ------------------------------------------------------------------- + +const changes = new EventTarget(); + +export const loadSaved = async () => + ((await storage.get("saved").catch(() => undefined)) as SavedRequest[] | undefined) ?? []; +export const loadHistory = async () => + ((await storage.get("history").catch(() => undefined)) as HistoryEntry[] | undefined) ?? []; + +async function writeSaved(list: SavedRequest[]) { + await storage.set("saved", list as unknown as Json); + changes.dispatchEvent(new Event("change")); +} + +async function remember(r: Request, res: Response) { + const entry: HistoryEntry = { ...r, status: res.status, ms: res.ms, at: Date.now() }; + await storage.set("history", addHistory(await loadHistory(), entry) as unknown as Json); + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()` again after every change made from this process. */ +export async function* live(load: () => Promise, signal: AbortSignal): AsyncGenerator { + while (!signal.aborted) { + yield await load(); + await new Promise((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +// --- Sending --------------------------------------------------------------------- + +const TIMEOUT = 30_000; + +/** Sends, records it in the history, and shows the response. */ +export async function sendAndShow(r: Request, ctx: Context): Promise { + const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(TIMEOUT)]); + let res: Response; + try { + res = await send(r, { signal }); + } catch (e) { + if (e instanceof DOMException && e.name === "TimeoutError") throw new Error(`No answer after ${TIMEOUT / 1000} s`); + const message = e instanceof Error ? e.message : String(e); + throw new Error(message.replace(/^error sending request for url \([^)]*\): /, "")); + } + await remember(r, res).catch(() => {}); + return responseView(r, res); +} + +const fence = (text: string, lang = "") => { + const ticks = "`".repeat(Math.max(3, ...[...text.matchAll(/`{3,}/g)].map((m) => m[0].length + 1))); + return `${ticks}${lang}\n${text}\n${ticks}`; +}; + +const LIMIT = 30_000; + +export function responseView(r: Request, res: Response): DetailSpec { + const status = `${res.status} ${reason(res.status, res.statusText)}`.trim(); + const tint = statusTint(res.status); + let body: string; + if (res.text === undefined) { + body = `_Binary body (${res.contentType || "unknown type"}), ${formatBytes(res.size)}._`; + } else if (!res.text) { + body = "_Empty body._"; + } else { + const pretty = prettyBody(res.contentType, res.text); + const shown = pretty.text.length > LIMIT + ? `${pretty.text.slice(0, LIMIT)}\n… (${formatBytes(res.size)} in all)` + : pretty.text; + body = fence(shown, pretty.lang); + } + const title = res.text && /html/.test(res.contentType) ? htmlTitle(res.text) : undefined; + return detail({ + title: status, + markdown: [`## ${status}`, "", `\`${r.method} ${r.url}\``, ...(title ? ["", `**${title}**`] : []), "", body].join( + "\n", + ), + stats: [ + { label: "Status", value: res.status, color: tint }, + { label: "Time", value: formatMs(res.ms) }, + { label: "Size", value: formatBytes(res.size) }, + ], + metadata: [ + meta.text("Method", r.method, { icon: Icons.arrowRight }), + meta.link("URL", shortUrl(res.url), res.url), + ...(res.redirected ? [meta.text("Redirected", "Yes, the URL above is the final one", { color: "purple" })] : []), + ...(res.contentType ? [meta.text("Content type", res.contentType, { icon: Icons.file })] : []), + meta.separator(), + ...res.headers.map(([k, v]) => meta.text(k, v)), + ], + status: `${status} · ${formatMs(res.ms)} · ${formatBytes(res.size)}`, + actions: [ + ...(res.text ? [copy(res.text, "Copy Body")] : []), + action("Send Again", async (c) => replace(await sendAndShow(r, c)), { icon: Icons.refresh, shortcut: "r" }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(headerText(res.headers), "Copy Response Headers", { section: "Copy" }), + copy(res.url, "Copy URL", { icon: Icons.link, section: "Copy" }), + ...(res.text && /json/.test(res.contentType) + ? [ + copy(prettyBody(res.contentType, res.text).text, "Copy Formatted JSON", { + icon: Icons.braces, + section: "Copy", + }), + ] + : []), + action("Save Request…", () => requestForm({ request: r }), { icon: Icons.bookmark, shortcut: "s" }), + ...(r.method === "GET" ? [open(res.url, "Open in Browser", { shortcut: "o" })] : []), + ], + }); +} + +// --- Form ------------------------------------------------------------------------ + +/** + * Creates or edits a saved request. `after` decides where saving leads: + * back to a live list (`pop`), or closes when it's the command itself. + */ +export function requestForm( + options: { request?: Request; saved?: SavedRequest; after?: "pop" | "close" } = {}, +): FormSpec { + const r = options.saved ?? options.request; + return form({ + title: options.saved ? `Edit “${options.saved.name}”` : "Save Request", + submitTitle: options.saved ? "Save Changes" : "Save Request", + fields: [ + field.text("name", "Name", { + required: true, + value: options.saved?.name ?? (r ? `${r.method} ${shortUrl(r.url)}` : ""), + placeholder: "List users (staging)", + }), + field.dropdown("method", "Method", METHODS, { value: r?.method ?? "GET" }), + field.text("url", "URL", { + required: true, + value: r?.url ?? "", + placeholder: "https://api.example.com/v1/users", + }), + field.textArea("headers", "Headers", { + value: r ? headerText(r.headers) : "", + placeholder: "Authorization: Bearer …\nAccept: application/json", + }), + field.textArea("body", "Body", { value: r?.body ?? "", placeholder: '{"name": "Ada"}' }), + ], + async submit(values) { + const request: Request = { + method: String(values.method || "GET"), + url: String(values.url).trim(), + headers: parseHeaders(String(values.headers ?? "")), + body: String(values.body ?? "") || undefined, + }; + validate(request); + const name = String(values.name).trim(); + const list = await loadSaved(); + const id = options.saved?.id ?? crypto.randomUUID(); + const entry: SavedRequest = { ...request, id, name }; + await writeSaved(options.saved ? list.map((s) => s.id === id ? entry : s) : [entry, ...list]); + if (options.after === "pop") return pop(); + await toast(`Saved “${name}”`, { style: "success" }); + return close(`Saved “${name}”`); + }, + }); +} + +// --- Items ----------------------------------------------------------------------- + +const methodTint = (m: string) => + ({ GET: "green", POST: "blue", PUT: "orange", PATCH: "purple", DELETE: "red" } as const)[m as "GET"] ?? "gray"; + +function requestDetail(r: Request) { + return { + markdown: [ + fence(toCurl(r), "sh"), + ...(r.body ? ["", "**Body**", "", fence(prettyBody(header(r, "content-type"), r.body).text)] : []), + ].join("\n"), + metadata: [ + meta.text("Method", r.method, { icon: Icons.arrowRight, color: methodTint(r.method) }), + meta.link("URL", shortUrl(r.url), r.url), + ...(r.headers.length + ? [ + meta.separator(), + ...r.headers.map(([k, v]) => meta.text(k, /authorization|token|key|cookie/i.test(k) ? "••••••" : v)), + ] + : []), + ], + }; +} + +const header = (r: Request, name: string) => r.headers.find(([k]) => k.toLowerCase() === name)?.[1] ?? ""; + +function requestActions(r: Request): Action[] { + return [ + action("Send Request", (ctx) => sendAndShow(r, ctx), { icon: Icons.arrowRight }), + copy(toCurl(r), "Copy as curl", { + concealed: hasSecret(r), + icon: Icons.terminal, + shortcut: "c", + section: "Copy", + }), + copy(r.url, "Copy URL", { icon: Icons.link, section: "Copy" }), + ]; +} + +export function savedItem(s: SavedRequest, after: "pop" | "close"): Item { + return { + id: `saved:${s.id}`, + title: s.name, + subtitle: `${s.method} ${shortUrl(s.url)}`, + icon: { source: Icons.bookmark, tint: methodTint(s.method) }, + section: "Saved", + keywords: [s.url, s.method], + accessories: [{ tag: s.method, color: methodTint(s.method) }], + detail: requestDetail(s), + actions: [ + ...requestActions(s), + action("Edit Request…", () => requestForm({ saved: s, after }), { icon: Icons.text, shortcut: "e" }), + action("Duplicate", async () => { + await writeSaved([{ ...s, id: crypto.randomUUID(), name: `${s.name} (copy)` }, ...await loadSaved()]); + await toast("Duplicated", { style: "success" }); + }, { icon: Icons.copy }), + action("Delete Request", async () => { + await writeSaved((await loadSaved()).filter((x) => x.id !== s.id)); + await toast(`Deleted “${s.name}”`); + }, { icon: Icons.trash, destructive: true, confirm: `Delete “${s.name}”?`, section: "Danger" }), + ], + }; +} + +export function historyItem(h: HistoryEntry, after: "pop" | "close"): Item { + return { + id: `history:${h.method}:${h.url}`, + title: shortUrl(h.url), + subtitle: h.method, + icon: { source: Icons.history, tint: statusTint(h.status) }, + section: "History", + keywords: [h.url, h.method, String(h.status)], + accessories: [ + { tag: String(h.status), color: statusTint(h.status) }, + { text: formatMs(h.ms), tooltip: "Response time" }, + { text: ago(h.at) }, + ], + detail: requestDetail(h), + actions: [ + ...requestActions(h), + action("Save Request…", () => requestForm({ request: h, after }), { icon: Icons.bookmark, shortcut: "s" }), + ], + }; +} + +export async function clearHistory() { + await storage.delete("history"); + changes.dispatchEvent(new Event("change")); +} diff --git a/extensions/joplin/backslash.toml b/extensions/joplin/backslash.toml new file mode 100644 index 0000000..13e19cc --- /dev/null +++ b/extensions/joplin/backslash.toml @@ -0,0 +1,33 @@ +[extension] +id = "joplin" +title = "Joplin" +description = "Search your Joplin notes by notebook, preview them with their tags, create notes and copy links" +version = "1.0.0" +author = "Backslash" +icon = "bs-note" +license = "MIT" + +# Joplin desktop serves its Data API on 127.0.0.1:41184 when the Web Clipper +# service is enabled; nothing else is reached. +[permissions] +net = ["127.0.0.1:41184"] + +[[commands]] +name = "search" +title = "Search Joplin Notes" +description = "Full-text search by notebook, with previews, tags and to-dos" +icon = "bs-search" +keywords = ["joplin", "notes", "notebook", "markdown", "search", "notes", "carnet", "chercher", "rechercher"] + +[[commands]] +name = "new" +title = "New Joplin Note" +description = "Title, markdown, notebook, tags; the last notebook is remembered" +icon = "bs-plus" +keywords = ["joplin", "note", "new", "create", "write", "nouvelle note", "créer", "écrire"] + +[[preferences]] +name = "token" +title = "API token" +description = "Joplin → Tools → Options → Web Clipper → Advanced options → Authorization token" +type = "secret" diff --git a/extensions/joplin/commands/new.ts b/extensions/joplin/commands/new.ts new file mode 100644 index 0000000..9efb293 --- /dev/null +++ b/extensions/joplin/commands/new.ts @@ -0,0 +1,14 @@ +import { action, type Context, Icons, list, refresh } from "@backslash/api"; +import { connect, noteForm, OfflineError, setupItems, TokenError } from "../joplin.ts"; + +export default async (ctx: Context) => { + try { + return await noteForm(await connect(ctx, ctx.signal), { title: ctx.query.trim(), standalone: true }); + } catch (e) { + if (!(e instanceof OfflineError || e instanceof TokenError)) throw e; + return list({ + items: setupItems(e), + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + }); + } +}; diff --git a/extensions/joplin/commands/search.ts b/extensions/joplin/commands/search.ts new file mode 100644 index 0000000..160e7e1 --- /dev/null +++ b/extensions/joplin/commands/search.ts @@ -0,0 +1,97 @@ +import { action, type Context, Icons, type Item, list, refresh } from "@backslash/api"; +import { + byUpdated, + descendants, + type Folder, + folderPaths, + type Note, + NOTE_FIELDS, + type Page, + searchQuery, +} from "../lib.ts"; +import { + type Client, + connect, + loadFolders, + noteForm, + noteItem, + noteTags, + OfflineError, + setupItems, + TokenError, +} from "../joplin.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + let client: Client; + let folders: Folder[]; + try { + client = await connect(ctx, ctx.signal); + folders = await loadFolders(client, ctx.signal); + } catch (e) { + if (!(e instanceof OfflineError || e instanceof TokenError)) throw e; + return list({ + items: setupItems(e), + status: "Joplin Data API · 127.0.0.1:41184", + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "r" })], + }); + } + const c = client; + const paths = folderPaths(folders); + const pathOf = new Map(paths.map((p) => [p.id, p.path])); + const top = paths.filter((p) => p.depth === 0); + return list({ + placeholder: "Search Joplin notes…", + filter: top.length > 1 + ? { + options: [ + { value: "all", title: "All Notebooks" }, + ...top.slice(0, 12).map((p) => ({ value: p.id, title: p.path })), + ], + } + : undefined, + empty: { title: "No matching note", description: "Press Ctrl+N to create one", icon: Icons.note }, + status: () => footer, + actions: [ + action("New Note", () => noteForm(c, { title: ctx.query }), { icon: Icons.plus, shortcut: "n" }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "r" }), + ], + async search(query, { filter, signal }) { + const q = searchQuery(query); + const inside = filter && filter !== "all" ? descendants(folders, filter) : undefined; + const params = { fields: NOTE_FIELDS, limit: 100 }; + const page = q + ? await c.get>("/search", { ...params, query: q, type: "note" }, signal) + : await c.get>("/notes", { ...params, order_by: "updated_time", order_dir: "DESC" }, signal); + let notes = page.items.filter((n) => !inside || inside.has(n.parent_id)); + if (!q) notes = byUpdated(notes); + notes = notes.slice(0, 40); + const tags = await Promise.all(notes.map((n) => noteTags(c, n.id, signal))); + const where = inside ? pathOf.get(filter!) : undefined; + footer = q + ? `${notes.length}${page.has_more ? "+" : ""} note${notes.length === 1 ? "" : "s"}${ + where ? ` in ${where}` : "" + }` + : `Recently updated${where ? ` in ${where}` : ""} · ${paths.length} notebooks`; + const items: Item[] = notes.map((n, i) => + noteItem(c, { note: n, notebook: pathOf.get(n.parent_id), tags: tags[i] }, q ? "Notes" : "Recently Updated") + ); + if (q) { + items.push({ + id: "create", + title: `Create “${query.trim()}”`, + subtitle: `New note${where ? ` in ${where}` : ""}`, + icon: { source: Icons.plus, tint: "green" }, + section: "Create", + actions: [ + action("New Note", () => noteForm(c, { title: query.trim(), folder: inside ? filter : undefined }), { + icon: Icons.plus, + }), + ], + }); + } + return items; + }, + }); +}; diff --git a/extensions/joplin/joplin.ts b/extensions/joplin/joplin.ts new file mode 100644 index 0000000..23a0b52 --- /dev/null +++ b/extensions/joplin/joplin.ts @@ -0,0 +1,342 @@ +// The Joplin Data API, served by the desktop app's Web Clipper service, and +// notes as launcher items. + +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + type Metadata, + open, + paste, + pop, + refresh, + storage, + toast, +} from "@backslash/api"; +import { + ago, + apiUrl, + BASE, + type Folder, + folderPaths, + markdownLink, + type Note, + openFolderUrl, + openNoteUrl, + type Page, + parseTags, + previewBody, + snippet, + type Tag, + todoState, + when, + wordCount, +} from "./lib.ts"; + +/** The Web Clipper service is off (or Joplin is closed). */ +export class OfflineError extends Error {} +/** No token, or a wrong one. */ +export class TokenError extends Error {} + +export interface Client { + token: string; + get(path: string, params?: Record, signal?: AbortSignal): Promise; + send(method: string, path: string, body: unknown): Promise; +} + +async function call(url: string, init: RequestInit): Promise { + try { + return await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(10_000) }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw e; + throw new OfflineError("Joplin's Web Clipper service is off"); + } +} + +async function parse(res: Response): Promise { + if (res.status === 403) throw new TokenError("Joplin refused the token"); + const text = await res.text(); + if (!res.ok) { + let message = text; + try { + message = JSON.parse(text).error ?? text; + } catch { /* plain text */ } + throw new Error(`Joplin: ${String(message).split("\n")[0] || res.status}`); + } + return (text ? JSON.parse(text) : undefined) as T; +} + +/** Checks the service is on (no token needed), then that a token is set. */ +export async function connect(ctx: Pick, signal?: AbortSignal): Promise { + const ping = await call(`${BASE}/ping`, { signal }); + if (!(await ping.text()).includes("JoplinClipperServer")) { + throw new OfflineError("Something else answers on port 41184"); + } + const token = String(ctx.preferences.token ?? "").trim(); + if (!token) throw new TokenError("Add your Joplin API token"); + return { + token, + get: async (path, params = {}, s) => parse(await call(apiUrl(path, params, token), { signal: s ?? signal })), + send: async (method, path, body) => + parse( + await call(apiUrl(path, {}, token), { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ), + }; +} + +/** Every page of a paginated list. */ +export async function all( + c: Client, + path: string, + params: Record, + signal?: AbortSignal, + max = 2000, +): Promise { + const out: T[] = []; + for (let page = 1; out.length < max; page++) { + const r = await c.get>(path, { ...params, limit: 100, page }, signal); + out.push(...r.items); + if (!r.has_more) break; + } + return out; +} + +export const loadFolders = (c: Client, signal?: AbortSignal) => + all(c, "/folders", { fields: "id,title,parent_id" }, signal); + +export async function noteTags(c: Client, id: string, signal?: AbortSignal): Promise { + const r = await c.get>(`/notes/${id}/tags`, { fields: "id,title" }, signal).catch(() => undefined); + return (r?.items ?? []).map((t) => t.title).sort(); +} + +// --- Setup ------------------------------------------------------------------- + +export function setupItems(e: OfflineError | TokenError): Item[] { + if (e instanceof OfflineError) { + return [{ + id: "offline", + title: "Joplin's Web Clipper service is off", + subtitle: "Open Joplin, then Tools → Options → Web Clipper → Enable", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: [ + "## Turn on the Web Clipper service", + "", + "Backslash reads your notes through the Data API of **Joplin desktop**, served by its Web Clipper service.", + "", + "1. Open Joplin", + "2. **Tools → Options → Web Clipper** (on macOS: Joplin → Settings)", + "3. **Enable Web Clipper Service**: it shows *Status: Enabled, port 41184*", + "", + "Joplin must stay open (it can be minimized). Another port than 41184 isn't supported.", + ].join("\n"), + }, + actions: [ + open("joplin://x-callback-url/openFolder?id=", "Open Joplin", { icon: Icons.external }), + open("https://joplinapp.org/help/apps/clipper", "Web Clipper Help", { icon: Icons.globe }), + open("https://joplinapp.org/download/", "Download Joplin", { icon: Icons.download }), + ], + }]; + } + return [{ + id: "token", + title: e.message, + subtitle: "Tools → Options → Web Clipper → Advanced options → Authorization token", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Joplin", + "", + "1. In Joplin: **Tools → Options → Web Clipper**", + "2. Under **Advanced options**, copy the **Authorization token**", + "3. Paste it in **Settings → Extensions → Joplin → Configure** (stored in your keyring)", + ].join("\n"), + }, + actions: [ + open("https://joplinapp.org/help/api/references/rest_api/#authorisation", "Token Help", { icon: Icons.globe }), + ], + }]; +} + +export async function guarded(load: () => Promise): Promise { + try { + return await load(); + } catch (e) { + if (e instanceof OfflineError || e instanceof TokenError) return setupItems(e); + throw e; + } +} + +// --- Notes ------------------------------------------------------------------- + +export interface Shown { + note: Note; + notebook?: string; + tags: string[]; +} + +function noteMetadata(s: Shown): Metadata[] { + const n = s.note; + const state = todoState(n); + const lines: Metadata[] = []; + if (s.notebook) lines.push(meta.text("Notebook", s.notebook, { icon: Icons.book })); + if (s.tags.length) lines.push(meta.tags("Tags", s.tags.map((t) => ({ text: t, color: "blue" as const })))); + if (state !== "note") { + lines.push(meta.text("To-do", state === "done" ? `Done ${ago(n.todo_completed)}` : "Open", { + icon: state === "done" ? Icons.done : Icons.todo, + color: state === "done" ? "green" : state === "overdue" ? "red" : undefined, + })); + if (n.todo_due) { + lines.push( + meta.text("Due", when(n.todo_due), { icon: Icons.bell, color: state === "overdue" ? "red" : undefined }), + ); + } + } + lines.push(meta.separator()); + lines.push(meta.text("Updated", `${when(n.updated_time)} · ${ago(n.updated_time)}`, { icon: Icons.clock })); + if (n.created_time) lines.push(meta.text("Created", when(n.created_time), { icon: Icons.calendar })); + lines.push(meta.text("Words", wordCount(n.body))); + if (n.author) lines.push(meta.person("Author", n.author)); + if (n.source_url) { + const host = (() => { + try { + return new URL(n.source_url).host; + } catch { + return n.source_url; + } + })(); + lines.push(meta.link("Source", host, n.source_url)); + } + return lines; +} + +export function notePage(c: Client, s: Shown): DetailSpec { + return detail({ + title: s.note.title || "Untitled", + markdown: `# ${s.note.title || "Untitled"}\n\n${previewBody(s.note.body ?? "", 50_000) || "*Empty note*"}`, + metadata: noteMetadata(s), + status: [s.notebook, `${wordCount(s.note.body)} words`, `updated ${ago(s.note.updated_time)}`].filter(Boolean) + .join(" · "), + actions: noteActions(c, s, { page: true }), + }); +} + +export function noteActions(c: Client, s: Shown, o: { page?: boolean } = {}): Action[] { + const n = s.note; + const state = todoState(n); + const body = (n.body ?? "").trim(); + return [ + open(openNoteUrl(n.id), "Open in Joplin", { icon: Icons.external }), + ...(o.page ? [] : [action("View Note", () => notePage(c, s), { icon: Icons.eye, shortcut: "o" })]), + ...(state === "note" ? [] : [ + action(state === "done" ? "Mark as Not Done" : "Mark as Done", async () => { + await c.send("PUT", `/notes/${n.id}`, { todo_completed: state === "done" ? 0 : Date.now() }); + await toast(state === "done" ? "Reopened" : "Done", { style: "success" }); + return o.page ? pop() : refresh(); + }, { icon: state === "done" ? Icons.todo : Icons.done, shortcut: "d" }), + ]), + copy(markdownLink(n), "Copy Markdown Link", { shortcut: "c", section: "Copy" }), + paste(markdownLink(n), "Paste Markdown Link", { shortcut: "v", section: "Copy" }), + copy(body, "Copy Markdown", { section: "Copy" }), + copy(openNoteUrl(n.id), "Copy Joplin URL", { icon: Icons.link, section: "Copy" }), + copy(n.title, "Copy Title", { section: "Copy" }), + ...(n.source_url ? [open(n.source_url, "Open Source Page", { icon: Icons.globe, section: "Open" })] : []), + open(openFolderUrl(n.parent_id), "Open Notebook in Joplin", { icon: Icons.book, section: "Open" }), + action("New Note", () => noteForm(c, { folder: n.parent_id }), { + icon: Icons.plus, + shortcut: "n", + section: "Open", + }), + ]; +} + +export function noteItem(c: Client, s: Shown, section?: string): Item { + const n = s.note; + const state = todoState(n); + const icon = state === "note" + ? { source: Icons.note, tint: "blue" as const } + : state === "done" + ? { source: Icons.squareCheck, tint: "green" as const } + : { source: Icons.square, tint: state === "overdue" ? "red" as const : "orange" as const }; + return { + id: n.id, + title: n.title || "Untitled", + subtitle: snippet(n.body) || undefined, + icon, + section, + accessories: [ + ...s.tags.slice(0, 2).map((t) => ({ tag: t, color: "blue" as const })), + ...(s.notebook ? [{ icon: Icons.book, text: s.notebook.split(" / ").pop(), tooltip: s.notebook }] : []), + { text: ago(n.updated_time), tooltip: `Updated ${when(n.updated_time)}` }, + ], + detail: { markdown: previewBody(n.body ?? "") || "*Empty note*", metadata: noteMetadata(s) }, + actions: noteActions(c, s), + }; +} + +// --- New note ---------------------------------------------------------------- + +export async function lastFolder(): Promise { + const f = await storage.get("lastFolder").catch(() => undefined); + return typeof f === "string" ? f : undefined; +} + +async function tagId(c: Client, title: string): Promise { + const found = await c.get>("/search", { type: "tag", query: title, fields: "id,title" }); + const hit = found.items.find((t) => t.title.toLowerCase() === title); + if (hit) return hit.id; + return (await c.send("POST", "/tags", { title })).id; +} + +export async function noteForm( + c: Client, + o: { folder?: string; title?: string; standalone?: boolean } = {}, +): Promise { + const [folders, remembered] = await Promise.all([loadFolders(c), lastFolder()]); + const paths = folderPaths(folders); + if (!paths.length) throw new Error("Create a notebook in Joplin first"); + const initial = [o.folder, remembered].find((id) => id && paths.some((p) => p.id === id)) ?? paths[0].id; + return form({ + title: "New Joplin Note", + submitTitle: "Create Note", + fields: [ + field.text("title", "Title", { required: true, placeholder: "Meeting notes", value: o.title ?? "" }), + field.textArea("body", "Note", { placeholder: "Markdown" }), + field.dropdown("folder", "Notebook", paths.map((p) => ({ value: p.id, title: p.path })), { value: initial }), + field.text("tags", "Tags", { placeholder: "work, ideas" }), + field.checkbox("todo", "To-do"), + ], + async submit(values) { + const title = String(values.title ?? "").trim(); + if (!title) throw new Error("Give the note a title"); + const parent_id = String(values.folder || initial); + const note = await c.send("POST", "/notes", { + title, + body: String(values.body ?? ""), + parent_id, + is_todo: values.todo === true ? 1 : 0, + }); + for (const t of parseTags(String(values.tags ?? ""))) { + await c.send("POST", `/tags/${await tagId(c, t)}/notes`, { id: note.id }); + } + await storage.set("lastFolder", parent_id); + const where = paths.find((p) => p.id === parent_id)?.path ?? "Joplin"; + await toast(`Note created in ${where}`, { style: "success" }); + return o.standalone ? close(`Created in ${where}`) : pop(); + }, + }); +} diff --git a/extensions/joplin/lib.ts b/extensions/joplin/lib.ts new file mode 100644 index 0000000..a0874dd --- /dev/null +++ b/extensions/joplin/lib.ts @@ -0,0 +1,184 @@ +// Joplin Data API (https://joplinapp.org/help/api/references/rest_api/) +// responses, links and text. Pure functions, no I/O. + +export const BASE = "http://127.0.0.1:41184"; +export const NOTE_FIELDS = + "id,parent_id,title,body,created_time,updated_time,is_todo,todo_completed,todo_due,source_url,author"; + +export interface Note { + id: string; + parent_id: string; + title: string; + body?: string; + created_time?: number; + updated_time?: number; + is_todo?: number; + todo_completed?: number; + todo_due?: number; + source_url?: string; + author?: string; +} + +export interface Folder { + id: string; + title: string; + parent_id: string; +} + +export interface Tag { + id: string; + title: string; +} + +export interface Page { + items: T[]; + has_more: boolean; +} + +/** `/search?query=…`, with the token and fields. */ +export function apiUrl(path: string, params: Record, token: string): string { + const url = new URL(path, BASE); + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + url.searchParams.set("token", token); + return url.toString(); +} + +/** + * What is typed, as a Joplin search: the last word gets a `*` so results + * come while typing (`proj` finds “project”), unless it is a filter + * (`tag:x`), a quoted phrase or already ends with `*`. + */ +export function searchQuery(input: string): string { + const q = input.trim().replace(/\s+/g, " "); + if (!q) return ""; + const words = q.split(" "); + const last = words[words.length - 1]; + if (/[:*"]/.test(last) || last.startsWith("-")) return q; + words[words.length - 1] = `${last}*`; + return words.join(" "); +} + +/** Notebooks with their full path, `Work / Projects / Alpha`, in tree order. */ +export function folderPaths(folders: Folder[]): { id: string; path: string; depth: number }[] { + const byId = new Map(folders.map((f) => [f.id, f])); + const path = (f: Folder, seen = new Set()): string[] => { + const parent = byId.get(f.parent_id); + if (!parent || seen.has(parent.id)) return [f.title]; + seen.add(f.id); + return [...path(parent, seen), f.title]; + }; + return folders + .map((f) => { + const p = path(f); + return { id: f.id, path: p.join(" / "), depth: p.length - 1, parts: p }; + }) + .sort((a, b) => { + for (let i = 0; i < Math.min(a.parts.length, b.parts.length); i++) { + const c = a.parts[i].localeCompare(b.parts[i], undefined, { sensitivity: "base" }); + if (c) return c; + } + return a.parts.length - b.parts.length; + }) + .map(({ id, path, depth }) => ({ id, path, depth })); +} + +/** A notebook and every notebook inside it. */ +export function descendants(folders: Folder[], root: string): Set { + const out = new Set([root]); + let grew = true; + while (grew) { + grew = false; + for (const f of folders) { + if (out.has(f.parent_id) && !out.has(f.id)) { + out.add(f.id); + grew = true; + } + } + } + return out; +} + +export const openNoteUrl = (id: string) => `joplin://x-callback-url/openNote?id=${encodeURIComponent(id)}`; +export const openFolderUrl = (id: string) => `joplin://x-callback-url/openFolder?id=${encodeURIComponent(id)}`; + +/** `[Title](:/id)`, the internal link Joplin itself copies. */ +export function markdownLink(note: Pick): string { + const title = (note.title || "Untitled").replace(/([[\]\\])/g, "\\$1"); + return `[${title}](:/${note.id})`; +} + +/** + * The body for the preview panel: Joplin resources (`![](:/id)`) can't be + * shown outside Joplin, so pictures become their caption and internal + * links their text. + */ +export function previewBody(body: string, max = 4000): string { + let text = body + .replace(/!\[([^\]]*)\]\(:\/[0-9a-f]{32}\)/g, (_, alt) => `*[image${alt ? `: ${alt}` : ""}]*`) + .replace(/]*src=["']:\/[0-9a-f]{32}["'][^>]*>/gi, "*[image]*") + .replace(/\[([^\]]*)\]\(:\/[0-9a-f]{32}(#[^)]*)?\)/g, "**$1**"); + if (text.length > max) text = `${text.slice(0, max).replace(/\s+\S*$/, "")}\n\n…`; + return text.trim(); +} + +/** The first line of text, without markdown marks, for the subtitle. */ +export function snippet(body: string | undefined, max = 90): string { + for (const raw of (body ?? "").split("\n")) { + const line = raw + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/^\s*([-*+] \[[ x]\]|#+|[-*+]|\d+\.|>)\s*/i, "") + .replace(/[*_`~]/g, "") + .trim(); + if (line) return line.length > max ? `${line.slice(0, max - 1)}…` : line; + } + return ""; +} + +export function wordCount(text: string | undefined): number { + return (text ?? "").split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; +} + +export function ago(ms: number | undefined, now = Date.now()): string { + if (!ms) return ""; + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "just now"; + const m = Math.round(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.round(d / 30.4); + if (mo < 12) return `${mo}mo ago`; + return `${Math.round(d / 365)}y ago`; +} + +export function when(ms: number | undefined): string { + return ms ? new Date(ms).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }) : ""; +} + +export type TodoState = "note" | "open" | "done" | "overdue"; + +export function todoState(n: Note, now = Date.now()): TodoState { + if (!n.is_todo) return "note"; + if (n.todo_completed) return "done"; + return n.todo_due && n.todo_due < now ? "overdue" : "open"; +} + +/** `work, ideas #later` → `["work", "ideas", "later"]`; Joplin lowercases tags. */ +export function parseTags(input: string): string[] { + return [ + ...new Set( + input.split(",").flatMap((t) => t.split(/\s+#/)).map((t) => t.trim().replace(/^#/, "").toLowerCase()).filter( + Boolean, + ), + ), + ]; +} + +/** Sorts recent notes, most recently updated first. */ +export function byUpdated(notes: Note[]): Note[] { + return [...notes].sort((a, b) => (b.updated_time ?? 0) - (a.updated_time ?? 0)); +} diff --git a/extensions/joplin/lib_test.ts b/extensions/joplin/lib_test.ts new file mode 100644 index 0000000..5b026a4 --- /dev/null +++ b/extensions/joplin/lib_test.ts @@ -0,0 +1,129 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + apiUrl, + byUpdated, + descendants, + type Folder, + folderPaths, + markdownLink, + openNoteUrl, + parseTags, + previewBody, + searchQuery, + snippet, + todoState, + wordCount, +} from "./lib.ts"; +import { noteItem } from "./joplin.ts"; + +const FOLDERS: Folder[] = [ + { id: "w", title: "Work", parent_id: "" }, + { id: "p", title: "Projects", parent_id: "w" }, + { id: "a", title: "Alpha", parent_id: "p" }, + { id: "h", title: "home", parent_id: "" }, + { id: "o", title: "Orphan", parent_id: "deleted-parent" }, +]; + +Deno.test("API URLs carry the token and skip empty params", () => { + assertEquals( + apiUrl("/search", { query: "a b*", type: "note", page: undefined, limit: 10 }, "tok"), + "http://127.0.0.1:41184/search?query=a+b*&type=note&limit=10&token=tok", + ); +}); + +Deno.test("search as you type", () => { + assertEquals(searchQuery(" meeting proj "), "meeting proj*"); + assertEquals(searchQuery("tag:work"), "tag:work"); + assertEquals(searchQuery('"exact phrase"'), '"exact phrase"'); + assertEquals(searchQuery("ide*"), "ide*"); + assertEquals(searchQuery("recipe -cake"), "recipe -cake"); + assertEquals(searchQuery(""), ""); +}); + +Deno.test("notebook paths in tree order, and what is inside one", () => { + assertEquals(folderPaths(FOLDERS), [ + { id: "h", path: "home", depth: 0 }, + { id: "o", path: "Orphan", depth: 0 }, + { id: "w", path: "Work", depth: 0 }, + { id: "p", path: "Work / Projects", depth: 1 }, + { id: "a", path: "Work / Projects / Alpha", depth: 2 }, + ]); + assertEquals([...descendants(FOLDERS, "w")].sort(), ["a", "p", "w"]); + assertEquals([...descendants(FOLDERS, "h")], ["h"]); + // A cycle (corrupted sync) doesn't hang. + folderPaths([{ id: "x", title: "X", parent_id: "y" }, { id: "y", title: "Y", parent_id: "x" }]); +}); + +Deno.test("links", () => { + assertEquals(markdownLink({ id: "0123abcd", title: "Plan [v2]" }), "[Plan \\[v2\\]](:/0123abcd)"); + assertEquals(markdownLink({ id: "1", title: "" }), "[Untitled](:/1)"); + assertEquals(openNoteUrl("abc"), "joplin://x-callback-url/openNote?id=abc"); +}); + +Deno.test("preview: resources become captions, internal links their text", () => { + const id = "0123456789abcdef0123456789abcdef"; + const body = + `# Trip\n\n![map](:/${id})\n\nSee [packing list](:/${id}#top) and [site](https://x.org).`; + assertEquals( + previewBody(body), + "# Trip\n\n*[image: map]*\n*[image]*\nSee **packing list** and [site](https://x.org).", + ); + assertEquals(previewBody("word ".repeat(50), 22), "word word word word\n\n…"); +}); + +Deno.test("snippet, words, tags", () => { + assertEquals(snippet("\n\n# Heading\nbody"), "Heading"); + assertEquals(snippet("- [ ] **Buy** [milk](https://shop) ![x](:/1)"), "Buy milk"); + assertEquals(snippet(undefined), ""); + assertEquals(snippet("a".repeat(100), 10), "aaaaaaaaa…"); + assertEquals(wordCount("Hello, world — 42 !"), 3); + assertEquals(parseTags("Work, ideas #Later,, work"), ["work", "ideas", "later"]); +}); + +Deno.test("to-do states and times", () => { + const now = 1_800_000_000_000; + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 0 }, now), "note"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1 }, now), "open"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1, todo_due: now - 1 }, now), "overdue"); + assertEquals(todoState({ id: "1", parent_id: "", title: "", is_todo: 1, todo_completed: now }, now), "done"); + assertEquals(ago(now - 30_000, now), "just now"); + assertEquals(ago(now - 5 * 3_600_000, now), "5h ago"); + assertEquals(ago(now - 3 * 86_400_000, now), "3d ago"); + assertEquals(ago(undefined, now), ""); + assertEquals( + byUpdated([{ id: "a", parent_id: "", title: "", updated_time: 1 }, { + id: "b", + parent_id: "", + title: "", + updated_time: 2, + }]) + .map((n) => n.id), + ["b", "a"], + ); +}); + +Deno.test("note item", () => { + const client = { token: "t", get: () => Promise.reject(), send: () => Promise.reject() }; + const item = noteItem(client, { + note: { + id: "n1", + parent_id: "a", + title: "Kickoff", + body: "## Agenda\n- scope", + updated_time: Date.now(), + is_todo: 1, + }, + notebook: "Work / Projects / Alpha", + tags: ["meeting"], + }); + assertEquals(item.subtitle, "Agenda"); + assertEquals(item.icon, { source: "bs-square", tint: "orange" }); + assertEquals(item.accessories?.map((a) => a.text ?? a.tag), ["meeting", "Alpha", "just now"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open in Joplin", + "View Note", + "Mark as Done", + "Copy Markdown Link", + ]); +}); diff --git a/extensions/keyboard-layouts/backslash.toml b/extensions/keyboard-layouts/backslash.toml new file mode 100644 index 0000000..5bcb793 --- /dev/null +++ b/extensions/keyboard-layouts/backslash.toml @@ -0,0 +1,61 @@ +[extension] +id = "keyboard-layouts" +title = "Keyboard Layouts" +description = "See the current keyboard layout and switch between layouts and input methods" +version = "1.0.0" +author = "Backslash" +icon = "bs-keyboard" +license = "MIT" + +# The desktop's own tools, the first that answers: gsettings (GNOME, plus gdbus to +# GNOME Shell), gdbus to org.kde.keyboard (KDE Plasma), hyprctl (Hyprland), +# swaymsg (Sway), setxkbmap and xkb-switch (X11). ibus and fcitx5-remote switch +# input methods. The xkb rules files give layout names; the fcitx5 profile lists +# its input methods. +[permissions] +run = ["gsettings", "gdbus", "hyprctl", "swaymsg", "setxkbmap", "xkb-switch", "ibus", "fcitx5-remote"] +read = [ + "/usr/share/X11/xkb/rules/evdev.lst", + "/usr/share/X11/xkb/rules/base.lst", + "~/.config/fcitx5/profile", + "/etc/os-release", +] +env = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "HOME", +] + +[[commands]] +name = "layouts" +title = "Keyboard Layouts" +description = "Your layouts and input methods, the current one marked" +icon = "bs-keyboard" +keywords = [ + "keyboard", + "clavier", + "layout", + "disposition", + "disposition du clavier", + "azerty", + "qwerty", + "language", + "langue", + "input method", + "méthode de saisie", + "ibus", + "fcitx", + "xkb", +] + +[[commands]] +name = "next-layout" +title = "Next Keyboard Layout" +description = "Switch to the next layout" +icon = "bs-keyboard" +mode = "no-view" +keywords = ["keyboard", "clavier", "layout", "disposition", "switch", "changer", "suivant", "next", "cycle"] diff --git a/extensions/keyboard-layouts/commands/layouts.ts b/extensions/keyboard-layouts/commands/layouts.ts new file mode 100644 index 0000000..d13c11e --- /dev/null +++ b/extensions/keyboard-layouts/commands/layouts.ts @@ -0,0 +1,156 @@ +import { + type Action, + action, + close, + copy, + Icons, + type Item, + list, + meta, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { BACKEND_NAMES, displayTag, type Family, installMarkdown, type Layout } from "../lib.ts"; +import { family, load, type State, switchTo } from "../system.ts"; + +const KIND_NAMES = { xkb: "Keyboard layout", ibus: "IBus input method", fcitx5: "Fcitx 5 input method" }; + +function layoutItem(l: Layout, s: State, section: string, position: number, count: number): Item { + const isMethod = l.kind !== "xkb" && !(l.kind === "fcitx5" && /^keyboard-/.test(l.key)); + const actions: Action[] = []; + if (!l.active) { + actions.push(action(`Switch to ${l.name}`, async () => { + await switchTo(s, l); + updateCommandMetadata({ subtitle: l.name }).catch(() => {}); + return close(`Keyboard: ${l.name}`); + }, { icon: Icons.keyboard })); + actions.push(action("Switch and Stay", async () => { + await switchTo(s, l); + await toast(`Switched to ${l.name}`, { style: "success" }); + return refresh(); + }, { icon: Icons.refresh, shortcut: "s" })); + } + actions.push( + copy(l.variant ? `${l.code}(${l.variant})` : l.code, "Copy Layout Code", { + section: "Copy", + shortcut: "c", + }), + copy(l.name, "Copy Name", { section: "Copy" }), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "r", section: "View" }), + ); + return { + id: `${l.kind}:${l.key}:${l.code}:${l.variant ?? ""}`, + title: l.name, + subtitle: l.active ? "Current" : undefined, + icon: { source: isMethod ? Icons.font : Icons.keyboard, tint: l.active ? "green" : isMethod ? "purple" : "blue" }, + section, + keywords: [l.code, l.variant ?? "", l.key, "layout", "disposition", "clavier", "keyboard"], + accessories: [ + ...(l.active ? [{ icon: Icons.check, color: "green" as const, tooltip: "Current layout" }] : []), + { tag: displayTag(l), color: l.active ? "green" : "gray" }, + ], + detail: { + stats: [ + { label: "Code", value: displayTag(l), color: l.active ? "green" : undefined }, + { label: "Position", value: `${position} of ${count}` }, + ], + metadata: [ + meta.text("Name", l.name, { icon: isMethod ? Icons.font : Icons.keyboard }), + meta.text("State", l.active ? "Current" : "Available", { + icon: l.active ? Icons.check : Icons.dottedCircle, + color: l.active ? "green" : "gray", + }), + meta.separator(), + meta.text("Type", KIND_NAMES[l.kind]), + meta.text(isMethod ? "Engine" : "Layout", l.kind === "xkb" || !isMethod ? l.code : l.key, { icon: Icons.code }), + ...(l.variant ? [meta.text("Variant", l.variant)] : []), + ...(s.backend ? [meta.text("Managed by", BACKEND_NAMES[s.backend], { icon: Icons.settings })] : []), + ], + }, + actions, + }; +} + +function setupItems(s: State, fam?: Family): Item[] { + if (s.backend) { + return [{ + id: "single", + title: "Only one layout is set up", + subtitle: s.backend ? BACKEND_NAMES[s.backend] : undefined, + icon: { source: Icons.info, tint: "blue" }, + detail: { + markdown: { + gnome: "Add layouts in **Settings → Keyboard → Input Sources**.", + kde: "Add layouts in **System Settings → Keyboard → Layouts**.", + hyprland: + "List several layouts in your Hyprland config:\n\n```\ninput {\n kb_layout = us,fr\n kb_variant = ,azerty\n}\n```", + sway: "List several layouts in your Sway config:\n\n```\ninput type:keyboard {\n xkb_layout us,fr\n}\n```", + x11: "Set several layouts, for example:\n\n`setxkbmap -layout us,fr -option grp:alt_shift_toggle`", + }[s.backend], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; + } + const x11 = s.wanted.includes("x11"); + if (s.error) { + return [{ + id: "error", + title: "Couldn't read the keyboard layouts", + subtitle: s.wanted.map((b) => BACKEND_NAMES[b]).join(", "), + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: "The desktop's keyboard tool answered with an error:\n\n```\n" + s.error + "\n```" }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh }), copy(s.error, "Copy Error")], + }]; + } + return [{ + id: "setup", + title: x11 ? "Install setxkbmap" : "No keyboard layout backend", + subtitle: "GNOME, KDE Plasma, Hyprland, Sway or X11", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: x11 + ? installMarkdown( + "On X11, Backslash reads layouts with **setxkbmap**, and switches with **xkb-switch** when it is installed.", + ["setxkbmap", "xkb-switch"], + fam, + ) + : s.wanted.includes("kde") + ? installMarkdown("On KDE Plasma, Backslash talks to the keyboard daemon with **gdbus**.", ["gdbus"], fam) + : "Backslash couldn't reach this desktop's keyboard settings. Supported: GNOME (gsettings), KDE Plasma (D-Bus), Hyprland (hyprctl), Sway (swaymsg) and X11 (setxkbmap).", + metadata: [meta.tags("Supported", ["GNOME", "KDE Plasma", "Hyprland", "Sway", "X11", "IBus", "Fcitx 5"])], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; +} + +let summary = ""; + +export default list({ + placeholder: "Filter keyboard layouts and input methods…", + empty: { title: "No layout matches", icon: Icons.keyboard }, + status: () => summary, + async items() { + const s = await load(); + const all = [...s.layouts, ...s.methods]; + const current = s.methods.find((m) => m.active && s.framework === "fcitx5") ?? s.layouts.find((l) => l.active) ?? + s.methods.find((m) => m.active); + updateCommandMetadata({ subtitle: current ? current.name : null }).catch(() => {}); + summary = [ + current ? `Current: ${current.name}` : undefined, + `${s.layouts.length} layout${s.layouts.length === 1 ? "" : "s"}`, + s.methods.length ? `${s.methods.length} in ${s.framework === "fcitx5" ? "Fcitx 5" : "IBus"}` : undefined, + s.backend ? BACKEND_NAMES[s.backend] : undefined, + ].filter(Boolean).join(" · "); + const items = [ + ...s.layouts.map((l, i) => + layoutItem(l, s, l.kind === "ibus" ? "Input Methods" : "Layouts", i + 1, s.layouts.length) + ), + ...s.methods.map((m, i) => + layoutItem(m, s, s.framework === "fcitx5" ? "Fcitx 5" : "IBus", i + 1, s.methods.length) + ), + ]; + if (all.length > 1) return items; + return [...items, ...setupItems(s, await family())]; + }, +}); diff --git a/extensions/keyboard-layouts/commands/next-layout.ts b/extensions/keyboard-layouts/commands/next-layout.ts new file mode 100644 index 0000000..04a0c36 --- /dev/null +++ b/extensions/keyboard-layouts/commands/next-layout.ts @@ -0,0 +1,15 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { nextLayout } from "../lib.ts"; +import { load, switchTo } from "../system.ts"; + +// Cycles through the desktop's layouts; with Fcitx 5 running, through its input methods. +export default run(async () => { + const s = await load(); + const pool = s.framework === "fcitx5" && s.methods.length > 1 ? s.methods : s.layouts; + if (!s.backend && !pool.length) throw new Error("No keyboard layout backend found: open Keyboard Layouts"); + const next = nextLayout(pool); + if (!next) throw new Error("Only one keyboard layout is set up"); + await switchTo(s, next); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return `Keyboard: ${next.name}`; +}); diff --git a/extensions/keyboard-layouts/lib.ts b/extensions/keyboard-layouts/lib.ts new file mode 100644 index 0000000..28f0deb --- /dev/null +++ b/extensions/keyboard-layouts/lib.ts @@ -0,0 +1,382 @@ +// Keyboard layouts and input methods from every desktop's own tools. Pure, +// tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Backend = "gnome" | "kde" | "hyprland" | "sway" | "x11"; +export type Env = Partial>; + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "HOME", +] as const; + +/** Backends worth trying here, best first; each is confirmed by asking it. */ +export function candidates(env: Env): Backend[] { + const desktop = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const out: Backend[] = []; + if (env.HYPRLAND_INSTANCE_SIGNATURE || desktop.includes("hyprland")) out.push("hyprland"); + if (env.SWAYSOCK || desktop.includes("sway")) out.push("sway"); + if (desktop.includes("kde")) out.push("kde"); + if (desktop.some((d) => ["gnome", "ubuntu", "unity", "pop", "budgie", "cinnamon"].includes(d))) out.push("gnome"); + if (env.DISPLAY && !wayland) out.push("x11"); + return out; +} + +export const BACKEND_NAMES: Record = { + gnome: "GNOME", + kde: "KDE Plasma", + hyprland: "Hyprland", + sway: "Sway", + x11: "X11", +}; + +// --------------------------------------------------------------------------- +// Layouts + +export type Kind = "xkb" | "ibus" | "fcitx5"; + +export interface Layout { + /** What the backend switches to: an index, a GNOME source, an engine name. */ + key: string; + kind: Kind; + /** XKB layout code (`fr`), or the engine name for input methods. */ + code: string; + variant?: string; + name: string; + active: boolean; +} + +/** `fr`, `fr+azerty`, `fr(azerty)` → code and variant. */ +export function splitLayout(spec: string): { code: string; variant?: string } { + const m = spec.match(/^([^+(]+)(?:\+([^)]+)|\(([^)]+)\))?$/); + if (!m) return { code: spec }; + const variant = m[2] ?? m[3]; + return variant ? { code: m[1], variant } : { code: m[1] }; +} + +/** "FR", "US", "DE·NEO": the short tag shown next to the layout. */ +export function tag(l: Pick): string { + if (l.kind !== "xkb") return l.kind === "ibus" ? "IBus" : "Fcitx"; + const base = l.code.toUpperCase(); + if (!l.variant) return base; + const v = l.variant.replace(/[-_].*/, ""); + return `${base}·${v.length > 6 ? v.slice(0, 5).toUpperCase() : v.toUpperCase()}`; +} + +export interface XkbNames { + layouts: Map; + /** Keyed by `code+variant`. */ + variants: Map; +} + +/** `/usr/share/X11/xkb/rules/evdev.lst` → layout and variant descriptions. */ +export function parseXkbList(text: string): XkbNames { + const layouts = new Map(); + const variants = new Map(); + let section = ""; + for (const line of text.split("\n")) { + const head = line.match(/^!\s*(\w+)/); + if (head) { + section = head[1]; + continue; + } + const m = line.match(/^\s+(\S+)\s+(.+)$/); + if (!m) continue; + if (section === "layout") layouts.set(m[1], m[2].trim()); + else if (section === "variant") { + const v = m[2].match(/^(\S+):\s*(.+)$/); + if (v) variants.set(`${v[1]}+${m[1]}`, v[2].trim()); + } + } + return { layouts, variants }; +} + +export function layoutName(names: XkbNames | undefined, code: string, variant?: string): string { + return (variant ? names?.variants.get(`${code}+${variant}`) : undefined) ?? names?.layouts.get(code) ?? + (variant ? `${code.toUpperCase()} (${variant})` : code.toUpperCase()); +} + +/** The layout and variant code of a description ("French (AZERTY)"), for backends that only give names. */ +export function codeOfName(names: XkbNames | undefined, name: string): { code: string; variant?: string } | undefined { + if (!names) return undefined; + for (const [key, desc] of names.variants) { + if (desc === name) { + const [code, variant] = key.split("+"); + return { code, variant }; + } + } + for (const [code, desc] of names.layouts) if (desc === name) return { code }; + return undefined; +} + +// --- GNOME ----------------------------------------------------------------- + +/** `gsettings get org.gnome.desktop.input-sources sources`: `[('xkb', 'us'), ('ibus', 'anthy')]`. */ +export function parseGnomeSources(text: string): [string, string][] { + return [...text.matchAll(/\(\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/g)].map((m) => [m[1], m[2]]); +} + +export const gnomeSourcesValue = (sources: [string, string][]) => + sources.length ? `[${sources.map(([t, id]) => `('${t}', '${id}')`).join(", ")}]` : "@a(ss) []"; + +/** GNOME's input sources, the current one being the first of `mru-sources` still configured. */ +export function gnomeLayouts(sourcesText: string, mruText: string, names?: XkbNames, ibusNames?: Map) { + const sources = parseGnomeSources(sourcesText); + const mru = parseGnomeSources(mruText); + const current = mru.find(([t, id]) => sources.some(([t2, id2]) => t === t2 && id === id2)) ?? sources[0]; + return sources.map(([type, id], i): Layout => { + const active = !!current && current[0] === type && current[1] === id; + if (type === "xkb") { + const { code, variant } = splitLayout(id); + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active }; + } + return { key: String(i), kind: "ibus", code: id, name: ibusNames?.get(id) ?? engineName(id), active }; + }); +} + +// --- KDE ------------------------------------------------------------------- + +/** `gdbus … org.kde.KeyboardLayouts.getLayoutsList`: `([('us', '', 'English (US)'), ('fr', 'azerty', 'French')],)`. */ +export function kdeLayouts(listOut: string, currentOut: string): Layout[] { + const current = Number(currentOut.replace(/u?int\d+/g, "").match(/(\d+)/)?.[1] ?? 0); + return [...listOut.matchAll(/\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'((?:[^'\\]|\\.)*)'\s*\)/g)].map((m, i) => ({ + key: String(i), + kind: "xkb", + code: m[1], + variant: m[2] || undefined, + name: m[3].replace(/\\(.)/g, "$1") || m[1].toUpperCase(), + active: i === current, + })); +} + +// --- Hyprland -------------------------------------------------------------- + +interface HyprKeyboard { + name: string; + layout: string; + variant: string; + active_keymap: string; + main?: boolean; + active_layout_index?: number; +} + +/** `hyprctl devices -j`: the main keyboard's layouts; the active one from its keymap name. */ +export function hyprLayouts(json: string, names?: XkbNames): { layouts: Layout[]; keyboards: string[] } { + const keyboards = ((JSON.parse(json) as { keyboards?: HyprKeyboard[] }).keyboards ?? []) + .filter((k) => !/virtual|power-button|video-bus|sleep-button/i.test(k.name)); + const main = keyboards.find((k) => k.main) ?? keyboards[0]; + if (!main) return { layouts: [], keyboards: [] }; + const codes = main.layout.split(",").map((s) => s.trim()); + const variants = (main.variant ?? "").split(",").map((s) => s.trim()); + const layouts = codes.filter(Boolean).map((code, i): Layout => { + const variant = variants[i] || undefined; + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active: false }; + }); + const index = typeof main.active_layout_index === "number" + ? main.active_layout_index + : layouts.findIndex((l) => l.name === main.active_keymap); + const fallback = layouts.findIndex((l) => names?.layouts.get(l.code) === main.active_keymap); + const active = index >= 0 ? index : fallback >= 0 ? fallback : 0; + if (layouts[active]) layouts[active].active = true; + return { layouts, keyboards: keyboards.map((k) => k.name) }; +} + +// --- Sway ------------------------------------------------------------------ + +/** `swaymsg -t get_inputs -r`: the first keyboard's layout names and active index. */ +export function swayLayouts(json: string, names?: XkbNames): Layout[] { + const inputs = JSON.parse(json) as { + type: string; + xkb_layout_names?: string[]; + xkb_active_layout_index?: number; + }[]; + const kb = inputs.find((i) => i.type === "keyboard" && (i.xkb_layout_names?.length ?? 0) > 1) ?? + inputs.find((i) => i.type === "keyboard"); + if (!kb?.xkb_layout_names) return []; + return kb.xkb_layout_names.map((name, i) => { + const c = codeOfName(names, name); + return { + key: String(i), + kind: "xkb", + code: c?.code ?? name.slice(0, 2).toLowerCase(), + variant: c?.variant, + name, + active: i === (kb.xkb_active_layout_index ?? 0), + }; + }); +} + +// --- X11 ------------------------------------------------------------------- + +export interface XkbQuery { + layouts: string[]; + variants: string[]; + options?: string; +} + +/** `setxkbmap -query`. */ +export function parseSetxkbmap(text: string): XkbQuery { + const get = (k: string) => text.match(new RegExp(`^${k}:\\s*(.*)$`, "m"))?.[1]?.trim() ?? ""; + const layouts = get("layout").split(",").map((s) => s.trim()); + const variants = get("variant").split(",").map((s) => s.trim()); + return { layouts: layouts.filter(Boolean), variants, options: get("options") || undefined }; +} + +/** X11 layouts; `current` is `xkb-switch -p` (`fr(azerty)`), else the first group is active. */ +export function x11Layouts(q: XkbQuery, current?: string, names?: XkbNames): Layout[] { + const cur = current ? splitLayout(current.trim()) : undefined; + let found = false; + const out = q.layouts.map((code, i): Layout => { + const variant = q.variants[i] || undefined; + const active = !found && !!cur && cur.code === code && (cur.variant ?? "") === (variant ?? ""); + if (active) found = true; + return { key: String(i), kind: "xkb", code, variant, name: layoutName(names, code, variant), active }; + }); + if (!found && out[0]) out[0].active = true; + return out; +} + +/** `setxkbmap` arguments putting layout `i` first, so it becomes the active group. */ +export function setxkbmapArgs(q: XkbQuery, i: number): string[] { + const order = [i, ...q.layouts.map((_, j) => j).filter((j) => j !== i)]; + const args = ["-layout", order.map((j) => q.layouts[j]).join(",")]; + if (q.variants.some(Boolean)) args.push("-variant", order.map((j) => q.variants[j] ?? "").join(",")); + return args; +} + +/** The `xkb-switch -s` argument for a layout. */ +export const xkbSwitchName = (l: Layout) => l.variant ? `${l.code}(${l.variant})` : l.code; + +// --------------------------------------------------------------------------- +// Input methods + +/** "mozc-jp" → "Mozc", "libpinyin" → "Pinyin": a readable name when the engine gives none. */ +export function engineName(id: string): string { + const known: Record = { + "mozc-jp": "Mozc (Japanese)", + anthy: "Anthy (Japanese)", + libpinyin: "Intelligent Pinyin", + pinyin: "Pinyin", + rime: "Rime", + hangul: "Hangul (Korean)", + chewing: "Chewing (Chinese)", + "m17n:hi:inscript": "Hindi (InScript)", + "unikey": "Unikey (Vietnamese)", + }; + if (known[id]) return known[id]; + const base = id.replace(/^keyboard-/, "").replace(/^xkb:/, "").replace(/::.*$/, ""); + return base.charAt(0).toUpperCase() + base.slice(1); +} + +/** `ibus list-engine`: ` anthy - Anthy` lines → names. */ +export function parseIbusEngines(text: string): Map { + const out = new Map(); + for (const m of text.matchAll(/^\s+(\S+)\s+-\s+(.+)$/gm)) out.set(m[1], m[2].trim()); + return out; +} + +/** `gsettings get org.freedesktop.ibus.general preload-engines`: `['xkb:us::eng', 'anthy']`. */ +export function parseStringList(text: string): string[] { + return [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => m[1]); +} + +/** `~/.config/fcitx5/profile`: the input methods of the first group, in order. */ +export function parseFcitxProfile(text: string): { name: string; layout?: string }[] { + const out: { name: string; layout?: string }[] = []; + let group = ""; + let item: { name?: string; layout?: string } | undefined; + const flush = () => { + if (item?.name) out.push({ name: item.name, layout: item.layout || undefined }); + item = undefined; + }; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const sec = line.match(/^\[(.+)\]$/); + if (sec) { + flush(); + group = sec[1]; + if (/^Groups\/0\/Items\/\d+$/.test(group)) item = {}; + continue; + } + if (!item) continue; + const kv = line.match(/^(\w+)=(.*)$/); + if (kv?.[1] === "Name") item.name = kv[2]; + if (kv?.[1] === "Layout") item.layout = kv[2]; + } + flush(); + return out; +} + +/** Input methods as layouts: `keyboard-fr-azerty` is an XKB layout under fcitx5. */ +export function fcitxLayouts(items: { name: string }[], current: string, names?: XkbNames): Layout[] { + return items.map((it) => { + const kb = it.name.match(/^keyboard-([a-z]+)(?:-(.+))?$/); + if (kb) { + return { + key: it.name, + kind: "fcitx5", + code: kb[1], + variant: kb[2], + name: layoutName(names, kb[1], kb[2]), + active: it.name === current, + }; + } + return { key: it.name, kind: "fcitx5", code: it.name, name: engineName(it.name), active: it.name === current }; + }); +} + +/** Tag for an fcitx5 keyboard layout: it is still an XKB layout to the user. */ +export const displayTag = (l: Layout) => + l.kind === "fcitx5" && /^keyboard-/.test(l.key) ? tag({ ...l, kind: "xkb" }) : tag(l); + +/** The layout after the active one, cycling. */ +export function nextLayout(layouts: T[]): T | undefined { + if (layouts.length < 2) return undefined; + const i = layouts.findIndex((l) => l.active); + return layouts[(i + 1) % layouts.length]; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record = { + arch: ["Arch", "sudo pacman -S"], + debian: ["Debian, Ubuntu", "sudo apt install"], + fedora: ["Fedora", "sudo dnf install"], + suse: ["openSUSE", "sudo zypper install"], +}; + +const PACKAGES: Record>> = { + setxkbmap: { debian: "x11-xkb-utils", fedora: "setxkbmap", arch: "xorg-setxkbmap", suse: "setxkbmap" }, + "xkb-switch": { debian: "xkb-switch", arch: "xkb-switch", fedora: "xkb-switch", suse: "xkb-switch" }, + gdbus: { debian: "libglib2.0-bin", arch: "glib2", fedora: "glib2", suse: "glib2-tools" }, +}; + +export function installMarkdown(what: string, programs: string[], family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + const lines = families.map((f) => + `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]} ${programs.map((p) => PACKAGES[p]?.[f] ?? p).join(" ")}\`` + ); + return `${what}\n\n${lines.join("\n\n")}`; +} diff --git a/extensions/keyboard-layouts/lib_test.ts b/extensions/keyboard-layouts/lib_test.ts new file mode 100644 index 0000000..45d0086 --- /dev/null +++ b/extensions/keyboard-layouts/lib_test.ts @@ -0,0 +1,198 @@ +import { assertEquals } from "@std/assert"; +import { + candidates, + codeOfName, + displayTag, + engineName, + fcitxLayouts, + gnomeLayouts, + gnomeSourcesValue, + hyprLayouts, + kdeLayouts, + layoutName, + nextLayout, + parseFcitxProfile, + parseGnomeSources, + parseIbusEngines, + parseSetxkbmap, + parseStringList, + parseXkbList, + setxkbmapArgs, + splitLayout, + swayLayouts, + tag, + x11Layouts, + xkbSwitchName, +} from "./lib.ts"; + +// An excerpt of /usr/share/X11/xkb/rules/evdev.lst (xkeyboard-config 2.41). +const LST = `! model + pc105 Generic 105-key PC + +! layout + us English (US) + fr French + de German + ru Russian + +! variant + intl us: English (US, intl., with dead keys) + azerty fr: French (AZERTY) + bepo fr: French (BEPO) + neo de: German (Neo 2) + +! option + grp Switching to another layout +`; +const NAMES = parseXkbList(LST); + +Deno.test("xkb names", () => { + assertEquals(layoutName(NAMES, "fr"), "French"); + assertEquals(layoutName(NAMES, "fr", "azerty"), "French (AZERTY)"); + assertEquals(layoutName(NAMES, "xx", "yy"), "XX (yy)"); + assertEquals(codeOfName(NAMES, "French (BEPO)"), { code: "fr", variant: "bepo" }); + assertEquals(codeOfName(NAMES, "Russian"), { code: "ru" }); + assertEquals(NAMES.layouts.has("pc105"), false); +}); + +Deno.test("layout specs and tags", () => { + assertEquals(splitLayout("fr+azerty"), { code: "fr", variant: "azerty" }); + assertEquals(splitLayout("fr(bepo)"), { code: "fr", variant: "bepo" }); + assertEquals(splitLayout("us"), { code: "us" }); + assertEquals(tag({ kind: "xkb", code: "fr" }), "FR"); + assertEquals(tag({ kind: "xkb", code: "us", variant: "intl" }), "US·INTL"); + assertEquals(tag({ kind: "xkb", code: "us", variant: "alt-intl" }), "US·ALT"); + assertEquals(tag({ kind: "ibus", code: "anthy" }), "IBus"); + assertEquals( + xkbSwitchName({ key: "1", kind: "xkb", code: "fr", variant: "azerty", name: "", active: false }), + "fr(azerty)", + ); +}); + +Deno.test("backends tried", () => { + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME", WAYLAND_DISPLAY: "wayland-0" }), ["gnome"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["kde", "x11"]); + assertEquals(candidates({ HYPRLAND_INSTANCE_SIGNATURE: "x", WAYLAND_DISPLAY: "w" }), ["hyprland"]); + assertEquals(candidates({ DISPLAY: ":0" }), ["x11"]); +}); + +Deno.test("gnome input sources, current from mru-sources", () => { + const sources = "[('xkb', 'us'), ('xkb', 'fr+azerty'), ('ibus', 'mozc-jp')]"; + const mru = "[('xkb', 'de'), ('xkb', 'fr+azerty'), ('xkb', 'us')]"; + const ls = gnomeLayouts(sources, mru, NAMES, new Map([["mozc-jp", "Mozc"]])); + assertEquals(ls.map((l) => [l.key, l.kind, l.code, l.variant, l.name, l.active]), [ + ["0", "xkb", "us", undefined, "English (US)", false], + ["1", "xkb", "fr", "azerty", "French (AZERTY)", true], + ["2", "ibus", "mozc-jp", undefined, "Mozc", false], + ]); + assertEquals(gnomeLayouts(sources, "@a(ss) []", NAMES)[0].active, true); + assertEquals(parseGnomeSources("@a(ss) []"), []); + assertEquals(gnomeSourcesValue([["xkb", "us"], ["ibus", "anthy"]]), "[('xkb', 'us'), ('ibus', 'anthy')]"); + assertEquals(gnomeSourcesValue([]), "@a(ss) []"); +}); + +Deno.test("kde layouts over gdbus", () => { + const ls = kdeLayouts("([('us', '', 'English (US)'), ('fr', 'azerty', 'French (AZERTY)')],)", "(uint32 1,)"); + assertEquals(ls.map((l) => [l.code, l.variant, l.name, l.active]), [ + ["us", undefined, "English (US)", false], + ["fr", "azerty", "French (AZERTY)", true], + ]); +}); + +Deno.test("hyprland main keyboard", () => { + const json = JSON.stringify({ + mice: [], + keyboards: [ + { name: "power-button", layout: "us", variant: "", active_keymap: "English (US)", main: false }, + { + name: "at-translated-set-2-keyboard", + layout: "us,fr", + variant: ",azerty", + active_keymap: "French (AZERTY)", + main: true, + }, + { name: "keychron-k2", layout: "us,fr", variant: ",azerty", active_keymap: "French (AZERTY)", main: false }, + ], + }); + const { layouts, keyboards } = hyprLayouts(json, NAMES); + assertEquals(layouts.map((l) => [l.code, l.variant, l.active]), [["us", undefined, false], ["fr", "azerty", true]]); + assertEquals(keyboards, ["at-translated-set-2-keyboard", "keychron-k2"]); + const indexed = hyprLayouts( + JSON.stringify({ + keyboards: [{ name: "kb", layout: "us,de", variant: "", active_keymap: "x", main: true, active_layout_index: 1 }], + }), + NAMES, + ); + assertEquals(indexed.layouts.map((l) => l.active), [false, true]); +}); + +Deno.test("sway inputs", () => { + const json = JSON.stringify([ + { identifier: "1:1:mouse", type: "pointer" }, + { identifier: "power", type: "keyboard", xkb_layout_names: ["English (US)"], xkb_active_layout_index: 0 }, + { + identifier: "1:1:AT_Translated_Set_2_keyboard", + type: "keyboard", + xkb_layout_names: ["English (US)", "French (BEPO)"], + xkb_active_layout_index: 1, + }, + ]); + assertEquals(swayLayouts(json, NAMES).map((l) => [l.key, l.code, l.variant, l.active]), [ + ["0", "us", undefined, false], + ["1", "fr", "bepo", true], + ]); +}); + +Deno.test("x11 setxkbmap and xkb-switch", () => { + const q = parseSetxkbmap( + "rules: evdev\nmodel: pc105\nlayout: us,fr,de\nvariant: ,azerty,\noptions: grp:alt_shift_toggle\n", + ); + assertEquals(q, { layouts: ["us", "fr", "de"], variants: ["", "azerty", ""], options: "grp:alt_shift_toggle" }); + assertEquals(x11Layouts(q, "fr(azerty)", NAMES).map((l) => l.active), [false, true, false]); + assertEquals(x11Layouts(q, undefined, NAMES).map((l) => l.active), [true, false, false]); + assertEquals(setxkbmapArgs(q, 1), ["-layout", "fr,us,de", "-variant", "azerty,,"]); + assertEquals(setxkbmapArgs(parseSetxkbmap("layout: us,ru\n"), 1), ["-layout", "ru,us"]); +}); + +Deno.test("input methods", () => { + const profile = `[Groups/0] +Name=Default +Default Layout=us +DefaultIM=mozc + +[Groups/0/Items/0] +Name=keyboard-us +Layout= + +[Groups/0/Items/1] +Name=keyboard-fr-azerty +Layout= + +[Groups/0/Items/2] +Name=mozc +Layout= + +[GroupOrder] +0=Default +`; + const items = parseFcitxProfile(profile); + assertEquals(items.map((i) => i.name), ["keyboard-us", "keyboard-fr-azerty", "mozc"]); + const ls = fcitxLayouts(items, "mozc", NAMES); + assertEquals(ls.map((l) => [l.code, l.variant, l.name, l.active]), [ + ["us", undefined, "English (US)", false], + ["fr", "azerty", "French (AZERTY)", false], + ["mozc", undefined, "Mozc", true], + ]); + assertEquals(displayTag(ls[1]), "FR·AZERTY"); + assertEquals(displayTag(ls[2]), "Fcitx"); + assertEquals(parseStringList("['xkb:us::eng', 'anthy']"), ["xkb:us::eng", "anthy"]); + assertEquals(parseIbusEngines("language: Japanese\n anthy - Anthy\n mozc-jp - Mozc\n").get("mozc-jp"), "Mozc"); + assertEquals(engineName("libpinyin"), "Intelligent Pinyin"); +}); + +Deno.test("next layout cycles", () => { + const ls = [{ active: false, n: 1 }, { active: true, n: 2 }, { active: false, n: 3 }]; + assertEquals(nextLayout(ls)?.n, 3); + assertEquals(nextLayout([{ active: false, n: 1 }, { active: true, n: 2 }])?.n, 1); + assertEquals(nextLayout([{ active: true }]), undefined); +}); diff --git a/extensions/keyboard-layouts/system.ts b/extensions/keyboard-layouts/system.ts new file mode 100644 index 0000000..b912a79 --- /dev/null +++ b/extensions/keyboard-layouts/system.ts @@ -0,0 +1,261 @@ +// Reading and switching layouts with the desktop's own tools. + +import { exec } from "@backslash/api"; +import { + type Backend, + candidates, + engineName, + type Env, + fcitxLayouts, + gnomeLayouts, + gnomeSourcesValue, + hyprLayouts, + kdeLayouts, + type Layout, + layoutName, + osFamily, + parseFcitxProfile, + parseGnomeSources, + parseIbusEngines, + parseSetxkbmap, + parseStringList, + parseXkbList, + SESSION_ENV, + setxkbmapArgs, + splitLayout, + swayLayouts, + x11Layouts, + type XkbNames, + xkbSwitchName, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key) || undefined; + } catch { + return undefined; + } +}; + +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); +const HOME = env("HOME") ?? ""; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const quiet = (p: Promise) => p.catch(() => undefined); + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +let xkbNames: XkbNames | undefined; +async function names(): Promise { + if (xkbNames) return xkbNames; + for (const f of ["/usr/share/X11/xkb/rules/evdev.lst", "/usr/share/X11/xkb/rules/base.lst"]) { + const text = await Deno.readTextFile(f).catch(() => ""); + if (text) return (xkbNames = parseXkbList(text)); + } + return undefined; +} + +const KDE = ["--dest", "org.kde.keyboard", "--object-path", "/Layouts"]; +const kdeCall = (method: string, ...args: string[]) => + exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + ...KDE, + "--method", + `org.kde.KeyboardLayouts.${method}`, + ...args, + ]); + +const gsettings = (...args: string[]) => exec("gsettings", args); +const SCHEMA = "org.gnome.desktop.input-sources"; + +export interface State { + backend?: Backend; + wanted: Backend[]; + layouts: Layout[]; + /** fcitx5 or IBus engines, outside the desktop's own list. */ + methods: Layout[]; + /** Which input method framework is running, if any. */ + framework?: "ibus" | "fcitx5"; + /** Hyprland: keyboard names, to switch each when "all" isn't understood. */ + keyboards?: string[]; + /** X11: whether xkb-switch is there to read and set the group. */ + xkbSwitch?: boolean; + /** Why an installed backend didn't answer. */ + error?: string; +} + +let lastError: string | undefined; + +async function read(b: Backend, n?: XkbNames): Promise | undefined> { + try { + switch (b) { + case "gnome": { + const [sources, mru] = await Promise.all([ + gsettings("get", SCHEMA, "sources"), + gsettings("get", SCHEMA, "mru-sources").catch(() => "[]"), + ]); + const parsed = parseGnomeSources(sources); + if (!parsed.length) { + // No source configured: GNOME uses the system layout. + return { layouts: [] }; + } + const ibus = parsed.some(([t]) => t === "ibus") + ? parseIbusEngines(await exec("ibus", ["list-engine"], { signal: AbortSignal.timeout(3000) }).catch(() => "")) + : undefined; + return { layouts: gnomeLayouts(sources, mru, n, ibus) }; + } + case "kde": { + const [list, current] = await Promise.all([kdeCall("getLayoutsList"), kdeCall("getLayout")]); + return { layouts: kdeLayouts(list, current) }; + } + case "hyprland": { + const { layouts, keyboards } = hyprLayouts(await exec("hyprctl", ["devices", "-j"]), n); + return { layouts, keyboards }; + } + case "sway": + return { layouts: swayLayouts(await exec("swaymsg", ["-t", "get_inputs", "-r"]), n) }; + case "x11": { + const q = parseSetxkbmap(await exec("setxkbmap", ["-query"])); + const current = await quiet(exec("xkb-switch", ["-p"])); + return { layouts: x11Layouts(q, current, n), xkbSwitch: current !== undefined }; + } + } + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) lastError = e instanceof Error ? e.message : String(e); + return undefined; + } +} + +/** fcitx5 (its own list) or IBus (outside GNOME, which lists engines with the layouts). */ +async function inputMethods(backend: Backend | undefined, n?: XkbNames): Promise> { + const state = await quiet(exec("fcitx5-remote", [])); + if (state !== undefined && state !== "0") { + const current = (await quiet(exec("fcitx5-remote", ["-n"]))) ?? ""; + const profile = await Deno.readTextFile(`${HOME}/.config/fcitx5/profile`).catch(() => ""); + const items = parseFcitxProfile(profile); + return { framework: "fcitx5", methods: fcitxLayouts(items.length ? items : [{ name: current }], current, n) }; + } + if (backend === "gnome") return { methods: [] }; + const engine = await quiet(exec("ibus", ["engine"], { signal: AbortSignal.timeout(2000) })); + if (engine === undefined) return { methods: [] }; + const preload = parseStringList( + (await quiet(gsettings("get", "org.freedesktop.ibus.general", "preload-engines"))) ?? "", + ); + const engines = preload.length ? preload : [engine]; + return { + framework: "ibus", + methods: engines.map((id) => { + const xkb = id.match(/^xkb:([^:]+):([^:]*):/); + return { + key: id, + kind: "ibus" as const, + code: xkb ? xkb[1] : id, + variant: xkb?.[2] || undefined, + name: xkb ? layoutName(n, xkb[1], xkb[2] || undefined) : engineName(id), + active: id === engine, + }; + }), + }; +} + +export async function load(): Promise { + const wanted = candidates(sessionEnv()); + const n = await names(); + let found: State | undefined; + for (const b of wanted) { + const s = await read(b, n); + if (s) { + found = { backend: b, wanted, layouts: s.layouts ?? [], methods: [], ...s } as State; + break; + } + } + const state: State = found ?? { wanted, layouts: [], methods: [], error: lastError }; + Object.assign(state, await inputMethods(state.backend, n)); + return state; +} + +// --------------------------------------------------------------------------- +// Switching + +/** GNOME has no public API to pick an input source; Eval works only in unsafe mode. */ +async function gnomeSwitch(index: number) { + const ok = await quiet(exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.gnome.Shell", + "--object-path", + "/org/gnome/Shell", + "--method", + "org.gnome.Shell.Eval", + `imports.ui.status.keyboard.getInputSourceManager().inputSources[${index}].activate(true)`, + ])); + if (ok?.startsWith("(true")) return; + // GNOME Shell keeps its most-recently-used order when the list changes, and + // activates the first one: leave only the chosen source for a moment, then + // put the list back. The chosen one stays first, so it stays active. + const text = await gsettings("get", SCHEMA, "sources"); + const sources = parseGnomeSources(text); + const chosen = sources[index]; + if (!chosen) throw new Error("This input source is gone"); + await gsettings("set", SCHEMA, "sources", gnomeSourcesValue([chosen])); + await sleep(350); + await gsettings("set", SCHEMA, "sources", text); + const mru = parseGnomeSources(await gsettings("get", SCHEMA, "mru-sources").catch(() => "[]")); + const rest = [...sources, ...mru].filter(([t, id]) => !(t === chosen[0] && id === chosen[1])); + const unique = rest.filter(([t, id], i) => rest.findIndex(([t2, id2]) => t === t2 && id === id2) === i); + await gsettings("set", SCHEMA, "mru-sources", gnomeSourcesValue([chosen, ...unique])).catch(() => {}); +} + +export async function switchTo(s: State, l: Layout): Promise { + if (l.kind === "fcitx5") { + await exec("fcitx5-remote", ["-s", l.key]); + return; + } + if (l.kind === "ibus" && s.backend !== "gnome") { + await exec("ibus", ["engine", l.key]); + return; + } + const i = Number(l.key); + switch (s.backend) { + case "gnome": + return await gnomeSwitch(i); + case "kde": { + const out = await kdeCall("setLayout", String(i)); + if (out.includes("false")) throw new Error("KDE refused to switch layout"); + return; + } + case "hyprland": + try { + await exec("hyprctl", ["switchxkblayout", "all", String(i)]); + } catch { + // Older Hyprland: one keyboard at a time. + for (const k of s.keyboards ?? []) await exec("hyprctl", ["switchxkblayout", k, String(i)]).catch(() => {}); + } + return; + case "sway": + await exec("swaymsg", ["input", "type:keyboard", "xkb_switch_layout", String(i)]); + return; + case "x11": { + if (s.xkbSwitch) { + await exec("xkb-switch", ["-s", xkbSwitchName(l)]); + return; + } + const q = parseSetxkbmap(await exec("setxkbmap", ["-query"])); + const at = q.layouts.findIndex((c, j) => c === l.code && (q.variants[j] || undefined) === l.variant); + await exec("setxkbmap", setxkbmapArgs(q, at >= 0 ? at : i)); + return; + } + default: + throw new Error("No keyboard layout backend found"); + } +} + +export { splitLayout }; diff --git a/extensions/logseq/backslash.toml b/extensions/logseq/backslash.toml new file mode 100644 index 0000000..40b8dcc --- /dev/null +++ b/extensions/logseq/backslash.toml @@ -0,0 +1,65 @@ +[extension] +id = "logseq" +title = "Logseq" +description = "Search your Logseq pages and blocks, preview them with properties and backlinks, open today's journal and capture blocks" +version = "1.0.0" +author = "Backslash" +icon = "bs-layers" +license = "MIT" + +# Graphs can be any folder of your home, so reading it is needed to search +# them (they are listed in ~/.logseq/graphs). Logseq has no link to add a +# block, so captures append to today's journal file directly: writing is +# allowed in the folders dedicated to Logseq only (~/Logseq, ~/logseq, +# ~/Documents/Logseq, ~/Documents/logseq, and the graphs inside them). For a +# graph elsewhere the block is copied and today's journal opened to paste it. +[permissions] +read = ["~"] +write = ["~/Logseq", "~/logseq", "~/Documents/Logseq", "~/Documents/logseq"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Search Logseq" +description = "Pages, journals and blocks of your graphs, with previews and backlinks" +icon = "bs-search" +keywords = ["logseq", "notes", "outliner", "pages", "blocks", "graph", "pkm", "chercher", "notes", "graphe"] + +[[commands]] +name = "journal" +title = "Open Logseq Journal" +description = "Today's journal page, created if needed" +icon = "bs-calendar" +mode = "no-view" +keywords = ["logseq", "journal", "today", "daily", "aujourd'hui", "journal du jour"] + +[[commands]] +name = "capture" +title = "Capture to Logseq" +description = "Append a block to today's journal" +icon = "bs-bolt" +mode = "no-view" +keywords = ["logseq", "capture", "block", "jot", "append", "inbox", "noter", "ajouter", "bloc"] +[[commands.arguments]] +name = "text" +placeholder = "Idea, TODO, link…" +required = true + +[[commands]] +name = "graphs" +title = "Logseq Graphs" +description = "Your graphs with their page and journal counts" +icon = "bs-layers" +keywords = ["logseq", "graph", "graphs", "graphe", "open"] + +[[preferences]] +name = "graph" +title = "Graph for the journal and captures" +description = "A graph name, or its folder (~/Logseq/work). Empty: the one opened last in Logseq" + +[[preferences]] +name = "timestamp" +title = "Prefix captures with the time" +description = "“- 14:05 your text”" +type = "boolean" +default = false diff --git a/extensions/logseq/commands/capture.ts b/extensions/logseq/commands/capture.ts new file mode 100644 index 0000000..a1ef69f --- /dev/null +++ b/extensions/logseq/commands/capture.ts @@ -0,0 +1,9 @@ +import { run } from "@backslash/api"; +import { capture } from "../graph.ts"; + +// "Capture to Logseq Call the bank" → a block at the end of today's journal. +export default run<{ text: string }>(async (ctx) => { + const text = String(ctx.args.text ?? ctx.query ?? "").trim(); + if (!text) throw new Error("Type the block after the command"); + return await capture(ctx, text); +}); diff --git a/extensions/logseq/commands/graphs.ts b/extensions/logseq/commands/graphs.ts new file mode 100644 index 0000000..7cf64a4 --- /dev/null +++ b/extensions/logseq/commands/graphs.ts @@ -0,0 +1,50 @@ +import { type Context, copy, Icons, list, meta, open } from "@backslash/api"; +import { ago, graphUri, plural } from "../lib.ts"; +import { indexGraph, loadGraphs, NO_GRAPH, pickGraph, tildify, todayJournal } from "../graph.ts"; + +export default async (ctx: Context) => { + const graphs = await loadGraphs(ctx); + const current = pickGraph(graphs, ctx); + return list({ + placeholder: "Logseq graphs…", + empty: NO_GRAPH, + status: `${graphs.length} graph${graphs.length === 1 ? "" : "s"}`, + items: await Promise.all(graphs.map(async (g) => { + const pages = await indexGraph(g); + const journals = pages.filter((p) => p.journal).length; + const blocks = pages.reduce((n, p) => n + p.page.blocks.length, 0); + const today = await todayJournal(g); + return { + id: g.path, + title: g.name, + subtitle: tildify(g.path), + icon: { source: Icons.layers, tint: g === current ? "teal" as const : "gray" as const }, + accessories: [ + ...(g === current ? [{ tag: "Journal & capture", color: "teal" as const }] : []), + { text: plural(pages.length - journals, "page") }, + ...(g.used && Number.isFinite(g.used) ? [{ text: ago(g.used), tooltip: "Last opened in Logseq" }] : []), + ], + detail: { + stats: [ + { label: "Pages", value: pages.length - journals }, + { label: "Journals", value: journals }, + { label: "Blocks", value: blocks.toLocaleString("en") }, + ], + metadata: [ + meta.text("Folder", tildify(g.path), { icon: Icons.folder }), + meta.text("Today", `${today.title}${today.exists ? "" : " (not created yet)"}`, { icon: Icons.calendar }), + ...(g === current + ? [meta.text("Used for", "Today's journal and captures", { icon: Icons.bolt, color: "teal" })] + : []), + ], + }, + actions: [ + open(graphUri(g.name), "Open in Logseq", { icon: Icons.external }), + open(today.uri, "Open Today's Journal", { icon: Icons.calendar, shortcut: "j" }), + open(g.path, "Show in Folder", { icon: Icons.folder, shortcut: "o" }), + copy(g.path, "Copy Folder Path", { shortcut: "c" }), + ], + }; + })), + }); +}; diff --git a/extensions/logseq/commands/journal.ts b/extensions/logseq/commands/journal.ts new file mode 100644 index 0000000..2d4dfbb --- /dev/null +++ b/extensions/logseq/commands/journal.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { openToday } from "../graph.ts"; + +export default run(async (ctx) => await openToday(ctx)); diff --git a/extensions/logseq/commands/search.ts b/extensions/logseq/commands/search.ts new file mode 100644 index 0000000..399e0bd --- /dev/null +++ b/extensions/logseq/commands/search.ts @@ -0,0 +1,76 @@ +import { action, type Context, Icons, type Item, list, open, refresh } from "@backslash/api"; +import { pageUri, plural, score } from "../lib.ts"; +import { blockItem, clearIndex, indexGraph, loadGraphs, NO_GRAPH, pageItem, todayJournal } from "../graph.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + const graphs = await loadGraphs(ctx); + const several = graphs.length > 1; + return list({ + placeholder: "Search pages and blocks…", + filter: several + ? { options: [{ value: "all", title: "All Graphs" }, ...graphs.map((g) => ({ value: g.path, title: g.name }))] } + : undefined, + empty: graphs.length + ? { title: "Nothing found", description: "Press Enter on “Create” to start a page", icon: Icons.note } + : NO_GRAPH, + status: () => footer, + actions: [action("Reload Graphs", () => (clearIndex(), refresh()), { icon: Icons.refresh, shortcut: "r" })], + async search(query, { filter }) { + const shown = !filter || filter === "all" ? graphs : graphs.filter((g) => g.path === filter); + if (!shown.length) return []; + const pages = (await Promise.all(shown.map((g) => indexGraph(g)))).flat(); + const q = query.trim(); + const items: Item[] = []; + if (!q) { + const journals = pages.filter((p) => p.journal).length; + footer = `${plural(pages.length - journals, "page")} · ${plural(journals, "journal")}${ + several ? ` in ${shown.length} graphs` : ` · ${shown[0].name}` + }`; + const today = await todayJournal(shown[0]); + items.push({ + id: "today", + title: today.exists ? "Open Today's Journal" : "Create Today's Journal", + subtitle: today.title, + icon: { source: Icons.calendar, tint: "teal" }, + section: "Journal", + accessories: several ? [{ text: shown[0].name }] : [], + actions: [open(today.uri, today.exists ? "Open Journal" : "Create Journal", { icon: Icons.calendar })], + }); + const recent = [...pages].sort((a, b) => b.modified - a.modified).slice(0, 40); + for (const p of recent) items.push(pageItem(p, { several, section: "Recently Modified" })); + return items; + } + const ranked = pages + .map((p) => ({ + p, + s: Math.max( + score(p.name, q) * 2, + ...p.aliases.map((a) => score(a, q) * 2), + ...p.tags.map((t) => score(t, q)), + ), + })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.p.backlinks - a.p.backlinks || b.p.modified - a.p.modified); + for (const { p } of ranked.slice(0, 25)) items.push(pageItem(p, { several, section: "Pages" })); + const blocks = pages + .flatMap((p) => p.page.blocks.map((b) => ({ p, b, s: score(b.content, q) }))) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || b.p.modified - a.p.modified); + for (const { p, b } of blocks.slice(0, 40)) items.push(blockItem(p, b, { several })); + footer = `${plural(ranked.length, "page")} · ${plural(blocks.length, "block")}`; + if (!ranked.some(({ p }) => p.name.toLowerCase() === q.toLowerCase())) { + items.push({ + id: "create", + title: `Create “${q}”`, + subtitle: several ? `New page in ${shown[0].name}` : "New page", + icon: { source: Icons.plus, tint: "green" }, + section: "Create", + actions: [open(pageUri(shown[0].name, q), "Create Page in Logseq", { icon: Icons.plus })], + }); + } + return items; + }, + }); +}; diff --git a/extensions/logseq/graph.ts b/extensions/logseq/graph.ts new file mode 100644 index 0000000..fb9d43e --- /dev/null +++ b/extensions/logseq/graph.ts @@ -0,0 +1,324 @@ +// Logseq graphs on disk, and their pages and blocks as launcher items. + +import { + type Action, + action, + clipboard, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + openExternal, + paste, +} from "@backslash/api"; +import { + ago, + appendBlock, + backlinkCounts, + type Block, + blockUri, + type Config, + formatDate, + type Graph, + graphFromCacheFile, + listProperty, + type Page, + pageKey, + pageNameFromFile, + pageUri, + parseConfig, + parseJournalFile, + parsePage, + plain, + previewMarkdown, + taskMarker, + wordCount, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +export const tildify = (p: string) => (HOME && p.startsWith(`${HOME}/`) ? `~${p.slice(HOME.length)}` : p); +const expand = (p: string) => (p === "~" ? HOME : p.startsWith("~/") ? `${HOME}${p.slice(1)}` : p); + +const isDir = (p: string) => Deno.stat(p).then((s) => s.isDirectory, () => false); + +/** Graphs Logseq knows (its cache in ~/.logseq/graphs), plus a folder set in the preferences; most recently used first. */ +export async function loadGraphs(ctx: Pick): Promise { + const found: Graph[] = []; + try { + for await (const e of Deno.readDir(`${HOME}/.logseq/graphs`)) { + const g = e.isFile ? graphFromCacheFile(e.name) : undefined; + if (!g) continue; + const stat = await Deno.stat(`${HOME}/.logseq/graphs/${e.name}`).catch(() => undefined); + found.push({ ...g, used: stat?.mtime?.getTime() }); + } + } catch { /* Logseq never ran */ } + const pref = String(ctx.preferences.graph ?? "").trim(); + if (pref.includes("/")) { + const path = expand(pref).replace(/\/+$/, ""); + if (!found.some((g) => g.path === path)) found.push({ name: path.split("/").pop()!, path, used: Infinity }); + } + const present = await Promise.all(found.map((g) => isDir(g.path))); + return found.filter((_, i) => present[i]).sort((a, b) => (b.used ?? 0) - (a.used ?? 0)); +} + +/** The graph for the journal and captures: the preference (a name or a folder), else the one used last. */ +export function pickGraph(graphs: Graph[], ctx: Pick): Graph | undefined { + const pref = String(ctx.preferences.graph ?? "").trim(); + if (pref) { + const path = expand(pref).replace(/\/+$/, ""); + const hit = graphs.find((g) => g.path === path || g.name.toLowerCase() === pref.toLowerCase()); + if (hit) return hit; + } + return graphs[0]; +} + +export async function graphConfig(g: Graph): Promise { + return parseConfig(await Deno.readTextFile(`${g.path}/logseq/config.edn`).catch(() => undefined)); +} + +export interface PageFile { + graph: Graph; + name: string; + /** Relative to the graph. */ + file: string; + journal?: Date; + page: Page; + modified: number; + tags: string[]; + aliases: string[]; + backlinks: number; +} + +const MAX_FILE = 2 * 1024 * 1024; + +async function mdFiles(dir: string): Promise { + const out: string[] = []; + try { + for await (const e of Deno.readDir(dir)) if (e.isFile && /\.md$/i.test(e.name)) out.push(e.name); + } catch { /* missing folder */ } + return out; +} + +const cache = new Map }>(); + +export const clearIndex = () => cache.clear(); + +/** Every page and journal of a graph, read once per minute at most. */ +export function indexGraph(g: Graph, maxAge = 60_000): Promise { + const hit = cache.get(g.path); + if (hit && Date.now() - hit.at < maxAge) return hit.pages; + const pages = (async () => { + const config = await graphConfig(g); + const files = [ + ...(await mdFiles(`${g.path}/${config.pagesDir}`)).map((f) => ({ dir: config.pagesDir, f, journal: false })), + ...(await mdFiles(`${g.path}/${config.journalsDir}`)).map((f) => ({ dir: config.journalsDir, f, journal: true })), + ]; + const out: PageFile[] = []; + for (let i = 0; i < files.length; i += 64) { + const batch = await Promise.all( + files.slice(i, i + 64).map(async ({ dir, f, journal }) => { + const full = `${g.path}/${dir}/${f}`; + try { + const stat = await Deno.stat(full); + const page = parsePage(stat.size > MAX_FILE ? "" : await Deno.readTextFile(full)); + const base = f.replace(/\.md$/i, ""); + const date = journal ? parseJournalFile(base, config.journalFile) : undefined; + const name = page.properties.title ?? + (date ? formatDate(config.journalTitle, date) : pageNameFromFile(base, config.tripleLowbar)); + return { + graph: g, + name, + file: `${dir}/${f}`, + journal: date, + page, + modified: stat.mtime?.getTime() ?? 0, + tags: listProperty(page.properties.tags), + aliases: listProperty(page.properties.alias), + backlinks: 0, + } satisfies PageFile; + } catch { + return undefined; + } + }), + ); + for (const p of batch) if (p) out.push(p); + } + const counts = backlinkCounts(out.map((p) => ({ name: p.name, page: p.page, aliases: p.aliases }))); + for (const p of out) p.backlinks = counts.get(pageKey(p.name)) ?? 0; + return out; + })(); + cache.set(g.path, { at: Date.now(), pages }); + pages.catch(() => cache.delete(g.path)); + return pages; +} + +// --- Today's journal ------------------------------------------------------------ + +export async function todayJournal(g: Graph, now = new Date()) { + const config = await graphConfig(g); + const ext = config.org ? "org" : "md"; + const file = `${g.path}/${config.journalsDir}/${formatDate(config.journalFile, now)}.${ext}`; + const title = formatDate(config.journalTitle, now); + const exists = await Deno.stat(file).then(() => true, () => false); + return { file, title, exists, config, uri: pageUri(g.name, title) }; +} + +export async function openToday(ctx: Pick): Promise { + const g = pickGraph(await loadGraphs(ctx), ctx); + if (!g) throw new Error("No Logseq graph found: open a folder in Logseq once, or set one in the preferences"); + const j = await todayJournal(g); + await openExternal(j.uri); + return `${j.title} · ${g.name}`; +} + +/** Folders dedicated to Logseq, where `[permissions] write` lets captures go (graphs in them or below). */ +export const WRITABLE = ["~/Logseq", "~/logseq", "~/Documents/Logseq", "~/Documents/logseq"]; + +export async function capture(ctx: Pick, text: string, now = new Date()): Promise { + const g = pickGraph(await loadGraphs(ctx), ctx); + if (!g) throw new Error("No Logseq graph found: open a folder in Logseq once, or set one in the preferences"); + const j = await todayJournal(g, now); + const p2 = (n: number) => String(n).padStart(2, "0"); + const time = ctx.preferences.timestamp === true ? `${p2(now.getHours())}:${p2(now.getMinutes())}` : undefined; + try { + const content = j.exists ? await Deno.readTextFile(j.file) : ""; + await Deno.mkdir(j.file.replace(/\/[^/]+$/, ""), { recursive: true }); + await Deno.writeTextFile(j.file, appendBlock(content, text, { org: j.config.org, time })); + cache.delete(g.path); + return `Added to ${j.title} · ${g.name}`; + } catch (e) { + if (!(e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied)) throw e; + // The graph is elsewhere: hand the block over through Logseq itself. + await clipboard.write(time ? `${time} ${text}` : text); + await openExternal(j.uri); + return `Copied: paste it in today's journal (Backslash may only write in ${WRITABLE.join(", ")})`; + } +} + +// --- Items -------------------------------------------------------------------- + +const when = (ms: number) => new Date(ms).toLocaleString("en", { dateStyle: "medium", timeStyle: "short" }); + +function propertyLines(props: Record): Metadata[] { + return Object.entries(props) + .filter(([k]) => !["tags", "alias", "title", "id", "collapsed", "filters", "public", "icon"].includes(k)) + .slice(0, 8) + .map(([k, v]) => { + const list = listProperty(v); + return v.includes("[[") || (v.includes(",") && list.length > 1) + ? meta.tags(k, list.slice(0, 6).map((t) => ({ text: t, color: "teal" as const }))) + : meta.text(k, v.length > 60 ? `${v.slice(0, 59)}…` : v); + }); +} + +export function pageMetadata(p: PageFile, several: boolean): Metadata[] { + const lines: Metadata[] = []; + if (several) lines.push(meta.text("Graph", p.graph.name, { icon: Icons.layers, color: "teal" })); + lines.push(meta.text("Type", p.journal ? "Journal" : "Page", { icon: p.journal ? Icons.calendar : Icons.note })); + if (p.tags.length) { + lines.push(meta.tags("Tags", p.tags.slice(0, 8).map((t) => ({ text: `#${t}`, color: "teal" as const })))); + } + if (p.aliases.length) lines.push(meta.tags("Aliases", p.aliases.slice(0, 6))); + lines.push(...propertyLines(p.page.properties)); + lines.push(meta.separator()); + lines.push(meta.text("Backlinks", p.backlinks, { icon: Icons.link, color: p.backlinks ? "teal" : undefined })); + lines.push(meta.text("Blocks", p.page.blocks.length, { icon: Icons.layers })); + lines.push(meta.text("Words", wordCount(p.page))); + lines.push(meta.text("Modified", when(p.modified), { icon: Icons.clock })); + lines.push(meta.text("File", p.file, { icon: Icons.file })); + return lines; +} + +function pageActions(p: PageFile, several: boolean, o: { page?: boolean } = {}): Action[] { + const uri = pageUri(p.graph.name, p.name); + const full = `${p.graph.path}/${p.file}`; + return [ + open(uri, "Open in Logseq", { icon: Icons.external }), + ...(o.page ? [] : [action("View Page", () => pagePage(p, several), { icon: Icons.eye, shortcut: "o" })]), + copy(`[[${p.name}]]`, "Copy Page Reference", { shortcut: "c", section: "Copy" }), + paste(`[[${p.name}]]`, "Paste Page Reference", { shortcut: "v", section: "Copy" }), + copy(uri, "Copy Logseq URL", { icon: Icons.link, section: "Copy" }), + copy(full, "Copy File Path", { section: "Copy" }), + open(full, "Open File in Default App", { icon: Icons.file, section: "Open" }), + open(full.replace(/\/[^/]+$/, ""), "Show in Folder", { icon: Icons.folder, section: "Open" }), + ]; +} + +export function pagePage(p: PageFile, several: boolean): DetailSpec { + return detail({ + title: p.name, + markdown: `# ${p.name}\n\n${previewMarkdown(p.page, 30_000) || "*Empty page*"}`, + metadata: pageMetadata(p, several), + status: `${p.graph.name} · ${p.page.blocks.length} blocks · ${p.backlinks} backlink${p.backlinks === 1 ? "" : "s"}`, + actions: pageActions(p, several, { page: true }), + }); +} + +export function pageItem(p: PageFile, o: { several: boolean; section?: string }): Item { + const first = p.page.blocks.find((b) => b.content)?.content; + return { + id: `${p.graph.path}/${p.file}`, + title: p.name, + subtitle: first ? plain(first.split("\n")[0]).slice(0, 100) : undefined, + icon: { source: p.journal ? Icons.calendar : Icons.note, tint: "teal" }, + section: o.section, + accessories: [ + ...p.tags.slice(0, 2).map((t) => ({ tag: `#${t}`, color: "teal" as const })), + ...(p.backlinks ? [{ icon: Icons.link, text: String(p.backlinks), tooltip: `${p.backlinks} backlinks` }] : []), + ...(o.several ? [{ text: p.graph.name }] : []), + { text: ago(p.modified), tooltip: `Modified ${when(p.modified)}` }, + ], + detail: { markdown: previewMarkdown(p.page, 3000) || "*Empty page*", metadata: pageMetadata(p, o.several) }, + actions: pageActions(p, o.several), + }; +} + +export function blockItem(p: PageFile, b: Block, o: { several: boolean }): Item { + const marker = taskMarker(b.content); + const text = plain(b.content.split("\n")[0]) || plain(b.content); + const uri = b.id ? blockUri(p.graph.name, b.id) : pageUri(p.graph.name, p.name); + return { + id: `${p.graph.path}/${p.file}#${b.id ?? text.slice(0, 40)}`, + title: text.slice(0, 120), + subtitle: p.name, + icon: marker + ? { source: marker === "DONE" ? Icons.squareCheck : Icons.square, tint: marker === "DONE" ? "green" : "orange" } + : { source: Icons.dot, tint: "teal" }, + section: "Blocks", + accessories: [...(marker ? [{ tag: marker }] : []), ...(o.several ? [{ text: p.graph.name }] : [])], + detail: { + markdown: previewMarkdown({ properties: {}, blocks: [{ ...b, depth: 0 }] }, 3000), + metadata: [ + meta.text("Page", p.name, { icon: p.journal ? Icons.calendar : Icons.note, color: "teal" }), + ...(o.several ? [meta.text("Graph", p.graph.name, { icon: Icons.layers })] : []), + ...propertyLines(b.properties), + meta.text("Modified", when(p.modified), { icon: Icons.clock }), + ], + }, + actions: [ + open(uri, b.id ? "Open Block in Logseq" : "Open Page in Logseq", { icon: Icons.external }), + ...(b.id ? [open(pageUri(p.graph.name, p.name), "Open Page in Logseq", { icon: Icons.note })] : []), + action("View Page", () => pagePage(p, o.several), { icon: Icons.eye, shortcut: "o" }), + copy(b.content, "Copy Block", { shortcut: "c", section: "Copy" }), + ...(b.id + ? [ + copy(`((${b.id}))`, "Copy Block Reference", { section: "Copy" }), + paste(`((${b.id}))`, "Paste Block Reference", { shortcut: "v", section: "Copy" }), + ] + : []), + copy(`[[${p.name}]]`, "Copy Page Reference", { section: "Copy" }), + ], + }; +} + +export const NO_GRAPH = { + title: "No Logseq graph found", + description: + "Open a folder in Logseq once (it is listed in ~/.logseq/graphs), or set a graph folder in Settings → Extensions → Logseq.", + icon: Icons.layers, +}; diff --git a/extensions/logseq/lib.ts b/extensions/logseq/lib.ts new file mode 100644 index 0000000..9c3c6f2 --- /dev/null +++ b/extensions/logseq/lib.ts @@ -0,0 +1,355 @@ +// Logseq graphs on disk: graph discovery, config.edn, file names, the +// outline format, references and journals. Pure functions, no I/O. + +export interface Graph { + name: string; + /** Absolute folder. */ + path: string; + /** When Logseq last used it (ms), from its cache file. */ + used?: number; +} + +/** + * `~/.logseq/graphs/logseq_local_++home++me++notes.transit` → `/home/me/notes`. + * Other cache files (Windows paths, iCloud, the GitHub sync of old versions) are ignored. + */ +export function graphFromCacheFile(file: string): Graph | undefined { + const m = /^logseq_local_(.+)\.transit$/.exec(file); + if (!m || !m[1].startsWith("++")) return undefined; + const path = m[1].replace(/\+\+/g, "/").replace(/\/+$/, ""); + const name = path.split("/").pop() ?? ""; + return name ? { name, path } : undefined; +} + +export interface Config { + journalFile: string; + journalTitle: string; + pagesDir: string; + journalsDir: string; + tripleLowbar: boolean; + org: boolean; +} + +export const DEFAULT_CONFIG: Config = { + journalFile: "yyyy_MM_dd", + journalTitle: "MMM do, yyyy", + pagesDir: "pages", + journalsDir: "journals", + tripleLowbar: false, + org: false, +}; + +/** The few keys of `logseq/config.edn` that decide where files are and what they are called. */ +export function parseConfig(edn: string | undefined): Config { + if (!edn) return { ...DEFAULT_CONFIG }; + const text = edn.split("\n").map((l) => l.replace(/(^|[^\\"]);.*$/, "$1")).join("\n"); + const str = (key: string) => new RegExp(`:${key.replace("/", "\\/")}\\s+"([^"]*)"`).exec(text)?.[1]; + const kw = (key: string) => new RegExp(`:${key.replace("/", "\\/")}\\s+:([\\w-]+)`).exec(text)?.[1]; + return { + journalFile: str("journal/file-name-format") ?? DEFAULT_CONFIG.journalFile, + journalTitle: str("journal/page-title-format") ?? DEFAULT_CONFIG.journalTitle, + pagesDir: str("pages-directory") ?? DEFAULT_CONFIG.pagesDir, + journalsDir: str("journals-directory") ?? DEFAULT_CONFIG.journalsDir, + tripleLowbar: kw("file/name-format") === "triple-lowbar", + org: /^org$/i.test(kw("preferred-format") ?? str("preferred-format") ?? ""), + }; +} + +// --- Dates (date-fns-like formats, as Logseq uses) ---------------------------- + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +export function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 + ? "th" + : ({ 1: "st", 2: "nd", 3: "rd" } as Record)[n % 10] ?? "th"; + return `${n}${s}`; +} + +/** `yyyy`, `yy`, `MMMM`, `MMM`, `MM`, `M`, `dd`, `do`, `d`, `EEEE`, `EEE`, `E`; anything else is kept. */ +export function formatDate(format: string, d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + const tokens: [string, () => string][] = [ + ["yyyy", () => String(d.getFullYear())], + ["yy", () => String(d.getFullYear()).slice(-2)], + ["MMMM", () => MONTHS[d.getMonth()]], + ["MMM", () => MONTHS[d.getMonth()].slice(0, 3)], + ["MM", () => p(d.getMonth() + 1)], + ["M", () => String(d.getMonth() + 1)], + ["dd", () => p(d.getDate())], + ["do", () => ordinal(d.getDate())], + ["d", () => String(d.getDate())], + ["EEEE", () => DAYS[d.getDay()]], + ["EEE", () => DAYS[d.getDay()].slice(0, 3)], + ["E", () => DAYS[d.getDay()].slice(0, 3)], + ]; + let out = ""; + for (let i = 0; i < format.length;) { + if (format[i] === "'") { + const close = format.indexOf("'", i + 1); + out += format.slice(i + 1, close < 0 ? undefined : close); + i = close < 0 ? format.length : close + 1; + continue; + } + const hit = tokens.find(([t]) => format.startsWith(t, i)); + if (hit) { + out += hit[1](); + i += hit[0].length; + } else out += format[i++]; + } + return out; +} + +/** `2026_10_28` with format `yyyy_MM_dd` → that date (only numeric formats are read back). */ +export function parseJournalFile(base: string, format: string): Date | undefined { + const order: string[] = []; + const re = new RegExp( + "^" + + format.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/yyyy|MM|dd/g, (t) => { + order.push(t); + return t === "yyyy" ? "(\\d{4})" : "(\\d{2})"; + }) + "$", + ); + const m = re.exec(base); + if (!m || order.length !== 3) return undefined; + const v = Object.fromEntries(order.map((t, i) => [t, Number(m[i + 1])])); + const date = new Date(v.yyyy, v.MM - 1, v.dd); + return date.getMonth() === v.MM - 1 ? date : undefined; +} + +// --- Page names -------------------------------------------------------------- + +/** A page's name from its file: `a___b.md` (triple-lowbar) or `a%2Fb.md` (legacy) → `a/b`. */ +export function pageNameFromFile(base: string, tripleLowbar: boolean): string { + let name = base; + if (tripleLowbar) name = name.replace(/___/g, "/"); + try { + name = decodeURIComponent(name); + } catch { /* not encoded */ } + return name; +} + +/** Logseq links pages case-insensitively. */ +export const pageKey = (name: string) => name.trim().toLowerCase(); + +export const pageUri = (graph: string, page: string) => + `logseq://graph/${encodeURIComponent(graph)}?page=${encodeURIComponent(page)}`; +export const blockUri = (graph: string, id: string) => + `logseq://graph/${encodeURIComponent(graph)}?block-id=${encodeURIComponent(id)}`; +export const graphUri = (graph: string) => `logseq://graph/${encodeURIComponent(graph)}`; + +// --- The outline ------------------------------------------------------------- + +export interface Block { + /** Text without its bullet and properties, continuation lines joined with `\n`. */ + content: string; + /** The `id::` property, set when the block was referenced or linked. */ + id?: string; + depth: number; + properties: Record; +} + +export interface Page { + properties: Record; + blocks: Block[]; +} + +const PROPERTY = /^\s*([A-Za-z0-9_\-/.]+)::\s?(.*)$/; + +/** Splits a Logseq markdown file into its page properties and blocks. */ +export function parsePage(text: string): Page { + const properties: Record = {}; + const blocks: Block[] = []; + type Open = { depth: number; indent: number; lines: string[]; props: Record; pageProps: boolean }; + let cur: Open | undefined; + let started = false; + let inCode = false; + const flush = () => { + if (!cur) return; + const content = cur.lines.join("\n").trim(); + if (content || cur.props.id) blocks.push({ content, id: cur.props.id, depth: cur.depth, properties: cur.props }); + cur = undefined; + }; + const add = (line: string) => { + if (/^\s*```/.test(line)) inCode = !inCode; + const prop = !inCode && PROPERTY.exec(line); + if (!prop) return void cur!.lines.push(line); + (cur!.pageProps ? properties : cur!.props)[prop[1].toLowerCase()] = prop[2].trim(); + }; + for (const line of text.replace(/\r/g, "").split("\n")) { + const bullet = inCode ? null : /^([ \t]*)[-*](?: (.*))?$/.exec(line); + if (bullet) { + flush(); + const ws = bullet[1].replace(/\t/g, " ").length; + const first = bullet[2] ?? ""; + // A first block made only of properties holds the page's properties. + const pageProps = !started && ws === 0 && PROPERTY.test(first); + started = true; + cur = { depth: Math.floor(ws / 2), indent: ws, lines: [], props: {}, pageProps }; + add(first); + continue; + } + if (!started) { + const prop = PROPERTY.exec(line); + if (prop) properties[prop[1].toLowerCase()] = prop[2].trim(); + else if (line.trim()) { + // Text before any bullet: a block of its own. + started = true; + cur = { depth: 0, indent: 0, lines: [], props: {}, pageProps: false }; + add(line.trim()); + } + continue; + } + if (!cur) continue; + // Continuation lines are indented two spaces past the bullet; deeper indentation is kept. + const ws = /^[ \t]*/.exec(line)![0].replace(/\t/g, " ").length; + add(" ".repeat(Math.max(0, ws - cur.indent - 2)) + line.replace(/^[ \t]*/, "")); + } + flush(); + return { properties, blocks }; +} + +/** `[[Page]]`, `#tag`, `#[[Long tag]]` in a text, lowercased, in order, once each. */ +export function references(text: string): string[] { + const out = new Set(); + const clean = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); + for (const m of clean.matchAll(/\[\[([^\]]+)\]\]/g)) out.add(pageKey(m[1])); + for (const m of clean.matchAll(/(?:^|\s)#([^\s#[\],.;:!?"'()]+)/g)) out.add(pageKey(m[1])); + return [...out]; +} + +/** `tags:: [[a]], b` or `alias:: x, [[y z]]` → `["a", "b"]`. */ +export function listProperty(value: string | undefined): string[] { + if (!value) return []; + return value.split(",").map((v) => v.trim().replace(/^\[\[(.*)\]\]$/, "$1").replace(/^#/, "")).filter(Boolean); +} + +/** How many blocks of other pages reference each page (key: lowercased name). */ +export function backlinkCounts(pages: { name: string; page: Page; aliases?: string[] }[]): Map { + const alias = new Map(); + for (const p of pages) for (const a of p.aliases ?? []) alias.set(pageKey(a), pageKey(p.name)); + const counts = new Map(); + for (const p of pages) { + const self = pageKey(p.name); + const tags = listProperty(p.page.properties.tags).map(pageKey); + for (const t of tags) if (t !== self) counts.set(alias.get(t) ?? t, (counts.get(alias.get(t) ?? t) ?? 0) + 1); + for (const b of p.page.blocks) { + const refs = new Set( + [...references(b.content), ...listProperty(b.properties.tags).map(pageKey)].map((r) => alias.get(r) ?? r), + ); + for (const r of refs) if (r !== self) counts.set(r, (counts.get(r) ?? 0) + 1); + } + } + return counts; +} + +/** Inline Logseq syntax made readable: `[[x]]` → x, TODO markers, `{{embed}}`, highlights. */ +export function plain(text: string): string { + return text + .replace(/^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELED|CANCELLED)\s+/, "") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/#\[\[([^\]]+)\]\]/g, "#$1") + .replace(/\{\{[^}]*\}\}/g, "") + .replace(/\(\([0-9a-f-]{36}\)\)/g, "…") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*_`~^=]{1,2}([^*_`~^=]+)[*_`~^=]{1,2}/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +export function taskMarker(text: string): string | undefined { + return /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELED|CANCELLED)\s/.exec(text)?.[1]; +} + +/** The outline as markdown for the preview panel. */ +export function previewMarkdown(page: Page, max = 4000): string { + const out: string[] = []; + let size = 0; + for (const b of page.blocks) { + const marker = taskMarker(b.content); + let text = b.content + .replace(/\[\[([^\]]+)\]\]/g, "**$1**") + .replace(/\(\(([0-9a-f-]{36})\)\)/g, "*(block ref)*") + .replace(/\{\{embed[^}]*\}\}/g, "*(embed)*"); + if (marker) { + text = text.replace( + /^\S+\s+/, + marker === "DONE" ? "[x] " : marker === "CANCELED" || marker === "CANCELLED" ? "~~" : "[ ] ", + ); + } + const indent = " ".repeat(Math.min(b.depth, 6)); + const lines = text.split("\n"); + const line = `${indent}- ${lines[0]}${lines.slice(1).map((l) => `\n${indent} ${l}`).join("")}`; + out.push(line); + size += line.length; + if (size > max) { + out.push("\n…"); + break; + } + } + return out.join("\n"); +} + +export function wordCount(page: Page): number { + return page.blocks.reduce((n, b) => n + b.content.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length, 0); +} + +// --- Capture ----------------------------------------------------------------- + +/** + * Appends a top-level block to a journal file. Several lines become one + * block with continuation lines; the file keeps a final line break. + */ +export function appendBlock(content: string, text: string, o: { org?: boolean; time?: string } = {}): string { + const lines = text.replace(/\r/g, "").trim().split("\n"); + const bullet = o.org ? "*" : "-"; + const first = `${bullet} ${o.time ? `${o.time} ` : ""}${lines[0]}`; + const block = [first, ...lines.slice(1).map((l) => ` ${l}`)].join("\n"); + // A new journal is just "-" (Logseq's empty first block): replace it. + const existing = content.replace(/\s+$/, ""); + if (!existing || existing === bullet) return `${block}\n`; + return `${existing}\n${block}\n`; +} + +// --- Search ------------------------------------------------------------------ + +const fold = (s: string) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + +/** Every word must be in the text; 3 per word at a word start, 1 inside a word. */ +export function score(text: string, query: string): number { + const t = fold(text); + let s = 0; + for (const w of fold(query).split(/\s+/).filter(Boolean)) { + const i = t.indexOf(w); + if (i < 0) return 0; + s += i === 0 || /[\s\[#(/_-]/.test(t[i - 1]) ? 3 : 1; + } + return s; +} + +export function ago(ms: number, now = Date.now()): string { + const m = Math.round((now - ms) / 60_000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + if (d < 30) return `${d}d ago`; + return d < 365 ? `${Math.round(d / 30.4)}mo ago` : `${Math.round(d / 365)}y ago`; +} + +export const plural = (n: number, word: string) => `${n.toLocaleString("en")} ${word}${n === 1 ? "" : "s"}`; diff --git a/extensions/logseq/lib_test.ts b/extensions/logseq/lib_test.ts new file mode 100644 index 0000000..7f10e10 --- /dev/null +++ b/extensions/logseq/lib_test.ts @@ -0,0 +1,178 @@ +import { assert, assertEquals } from "@std/assert"; +import { + appendBlock, + backlinkCounts, + blockUri, + DEFAULT_CONFIG, + formatDate, + graphFromCacheFile, + listProperty, + ordinal, + pageNameFromFile, + pageUri, + parseConfig, + parseJournalFile, + parsePage, + plain, + previewMarkdown, + references, + score, + taskMarker, +} from "./lib.ts"; +import { pickGraph } from "./graph.ts"; + +const PAGE = `title:: Project Alpha +tags:: [[project]], work +alias:: Alpha +status:: active + +- Kickoff with [[Bob Smith]] #meeting + id:: 6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b + collapsed:: true + - TODO Write the brief + SCHEDULED: <2026-10-30 Fri> + - DONE Book the room #[[Meeting Rooms]] +- Notes + continued on a second line + \`\`\`js + - not a block:: really + \`\`\` +- +`; + +Deno.test("graphs from Logseq's cache files", () => { + assertEquals(graphFromCacheFile("logseq_local_++home++me++Documents++notes.transit"), { + name: "notes", + path: "/home/me/Documents/notes", + }); + assertEquals(graphFromCacheFile("logseq_local_C:++Users++me++notes.transit"), undefined); + assertEquals(graphFromCacheFile("logseq_local_++home++me++notes++.transit")?.path, "/home/me/notes"); + assertEquals(graphFromCacheFile("something.json"), undefined); +}); + +Deno.test("config.edn keys, comments ignored", () => { + const edn = `{:meta/version 1 + ;; :journal/page-title-format "yyyy-MM-dd" + :journal/page-title-format "EEE, dd.MM.yyyy" + :journal/file-name-format "yyyy-MM-dd" + :preferred-format :markdown + :file/name-format :triple-lowbar + :journals-directory "daily"}`; + assertEquals(parseConfig(edn), { + journalTitle: "EEE, dd.MM.yyyy", + journalFile: "yyyy-MM-dd", + pagesDir: "pages", + journalsDir: "daily", + tripleLowbar: true, + org: false, + }); + assertEquals(parseConfig(undefined), DEFAULT_CONFIG); + assertEquals(parseConfig(`{:preferred-format "Org"}`).org, true); +}); + +Deno.test("journal dates", () => { + const d = new Date(2026, 9, 28); + assertEquals(formatDate("MMM do, yyyy", d), "Oct 28th, 2026"); + assertEquals(formatDate("yyyy_MM_dd", d), "2026_10_28"); + assertEquals(formatDate("EEEE, MMMM d, yyyy", new Date(2026, 0, 1)), "Thursday, January 1, 2026"); + assertEquals(formatDate("EEE, dd.MM.yy", new Date(2026, 1, 3)), "Tue, 03.02.26"); + assertEquals(formatDate("do 'of' MMMM", new Date(2026, 4, 22)), "22nd of May"); + assertEquals( + [1, 2, 3, 4, 11, 12, 13, 21, 22, 23].map(ordinal).join(" "), + "1st 2nd 3rd 4th 11th 12th 13th 21st 22nd 23rd", + ); + assertEquals(parseJournalFile("2026_10_28", "yyyy_MM_dd")?.getDate(), 28); + assertEquals(parseJournalFile("2026-02-30", "yyyy-MM-dd"), undefined); + assertEquals(parseJournalFile("notes", "yyyy_MM_dd"), undefined); +}); + +Deno.test("page names from files", () => { + assertEquals(pageNameFromFile("projects___alpha", true), "projects/alpha"); + assertEquals(pageNameFromFile("projects%2Falpha", false), "projects/alpha"); + assertEquals(pageNameFromFile("What%3F", false), "What?"); + assertEquals(pageNameFromFile("100% sure", false), "100% sure"); + assertEquals(pageUri("my notes", "Oct 28th, 2026"), "logseq://graph/my%20notes?page=Oct%2028th%2C%202026"); + assertEquals(blockUri("notes", "abc"), "logseq://graph/notes?block-id=abc"); +}); + +Deno.test("the outline: page properties, blocks, block properties, code", () => { + const page = parsePage(PAGE); + assertEquals(page.properties, { + title: "Project Alpha", + tags: "[[project]], work", + alias: "Alpha", + status: "active", + }); + assertEquals(page.blocks.map((b) => [b.depth, b.content]), [ + [0, "Kickoff with [[Bob Smith]] #meeting"], + [1, "TODO Write the brief\nSCHEDULED: <2026-10-30 Fri>"], + [1, "DONE Book the room #[[Meeting Rooms]]"], + [0, "Notes\ncontinued on a second line\n```js\n- not a block:: really\n```"], + ]); + assertEquals(page.blocks[0].id, "6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b"); + assertEquals(page.blocks[0].properties.collapsed, "true"); + // Page properties in a first bullet, as Logseq writes them in some versions. + const bulleted = parsePage("- tags:: a, b\n type:: book\n- First block\n"); + assertEquals(bulleted.properties, { tags: "a, b", type: "book" }); + assertEquals(bulleted.blocks.map((b) => b.content), ["First block"]); + // A file without bullets is one block. + assertEquals(parsePage("Just text\n").blocks.map((b) => b.content), ["Just text"]); + assertEquals(parsePage("").blocks, []); +}); + +Deno.test("references, list properties and backlinks", () => { + assertEquals(references("See [[Bob Smith]] and #meeting, #[[Meeting Rooms]] `#not` [[bob smith]]"), [ + "bob smith", + "meeting rooms", + "meeting", + ]); + assertEquals(references("C# and issue#12 and https://x.org/#anchor"), []); + assertEquals(listProperty("[[project]], work, #later"), ["project", "work", "later"]); + const alpha = { name: "Project Alpha", page: parsePage(PAGE), aliases: ["Alpha"] }; + const journal = { + name: "Oct 28th, 2026", + page: parsePage("- Met [[Alpha]] team\n- [[Project Alpha]] again #meeting\n"), + }; + const counts = backlinkCounts([alpha, journal, { name: "Project", page: parsePage("- [[project]] self ref") }]); + assertEquals(counts.get("project alpha"), 2); // via its alias, and by name + assertEquals(counts.get("meeting"), 2); + assertEquals(counts.get("project"), 1); // tags:: of Project Alpha; its own self-reference doesn't count + assertEquals(counts.get("bob smith"), 1); +}); + +Deno.test("readable text and previews", () => { + assertEquals( + plain("TODO Call [[Bob Smith]] about **the** ((6720f1b2-1c3d-4e5f-8a9b-0c1d2e3f4a5b)) {{embed [[x]]}}"), + "Call Bob Smith about the …", + ); + assertEquals(taskMarker("DONE x"), "DONE"); + assertEquals(taskMarker("TODOS x"), undefined); + const md = previewMarkdown(parsePage(PAGE)); + assert(md.startsWith("- Kickoff with **Bob Smith** #meeting\n - [ ] Write the brief\n SCHEDULED")); + assert(md.includes(" - [x] Book the room")); +}); + +Deno.test("capturing a block", () => { + assertEquals(appendBlock("", "Call the bank"), "- Call the bank\n"); + assertEquals(appendBlock("-\n", "Call the bank"), "- Call the bank\n"); // Logseq's empty new journal + assertEquals(appendBlock("- one", "two"), "- one\n- two\n"); + assertEquals(appendBlock("- one\n\n\n", "two\nmore", { time: "14:05" }), "- one\n- 14:05 two\n more\n"); + assertEquals(appendBlock("* one\n", "two", { org: true }), "* one\n* two\n"); +}); + +Deno.test("search scoring", () => { + assertEquals(score("Project Alpha", "alp"), 3); + assertEquals(score("Project Alpha", "pha"), 1); + assertEquals(score("Project Alpha", "proj alp"), 6); + assertEquals(score("Project Alpha", "beta"), 0); + assertEquals(score("Café notes", "cafe"), 3); +}); + +Deno.test("the graph for journal and captures", () => { + const graphs = [{ name: "work", path: "/h/Logseq/work", used: 2 }, { name: "notes", path: "/h/notes", used: 1 }]; + assertEquals(pickGraph(graphs, { preferences: {} })?.name, "work"); + assertEquals(pickGraph(graphs, { preferences: { graph: "Notes" } })?.name, "notes"); + assertEquals(pickGraph(graphs, { preferences: { graph: "/h/notes/" } })?.name, "notes"); + assertEquals(pickGraph(graphs, { preferences: { graph: "missing" } })?.name, "work"); + assertEquals(pickGraph([], { preferences: {} }), undefined); +}); diff --git a/extensions/movies/api.ts b/extensions/movies/api.ts new file mode 100644 index 0000000..0465791 --- /dev/null +++ b/extensions/movies/api.ts @@ -0,0 +1,175 @@ +// The TMDB API (v3 endpoints, with a v4 read access token or a v3 key), and the watchlist. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { auth, fromDetails, fromResult, type Kind, type Title, type TmdbDetails, type TmdbResult } from "./lib.ts"; + +const API = "https://api.themoviedb.org/3"; +export const TOKEN_URL = "https://www.themoviedb.org/settings/api"; + +type Json = Parameters[1]; + +export class AuthError extends Error {} + +export async function load(key: string): Promise { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise { + await storage.set(key, value as Json).catch(() => {}); +} + +export interface Account { + token: string; + region: string; + language: string; +} + +export function token(preferences: Record): string { + const t = String(preferences.token ?? "").trim(); + if (!t) throw new AuthError("Add your TMDB API Read Access Token"); + return t; +} + +async function tmdb(a: Account, path: string, signal?: AbortSignal): Promise { + const { headers, param } = auth(a.token); + const sep = path.includes("?") ? "&" : "?"; + let res: Response; + try { + res = await fetch(`${API}${path}${param ? `${sep}${param}` : ""}`, { + signal, + headers: { ...headers, Accept: "application/json" }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`TMDB is unreachable: ${(e as Error).message}`); + } + if (res.status === 401) { + await res.body?.cancel(); + throw new AuthError("TMDB rejected the token"); + } + if (res.status === 429) { + await res.body?.cancel(); + throw new Error("TMDB is rate limiting, try again in a few seconds"); + } + if (!res.ok) { + const body = await res.json().catch(() => undefined) as { status_message?: string } | undefined; + throw new Error(`TMDB answered ${res.status}${body?.status_message ? `: ${body.status_message}` : ""}`); + } + return await res.json() as T; +} + +const page = (r: { results?: TmdbResult[] }, kind?: Kind) => + (r.results ?? []).filter((x) => !x.adult).map((x) => fromResult(x, kind)).filter((t): t is Title => !!t); + +export async function trending(a: Account, kind: "all" | Kind, signal?: AbortSignal): Promise { + const r = await tmdb<{ results?: TmdbResult[] }>(a, `/trending/${kind}/week?language=${a.language}`, signal); + return page(r, kind === "all" ? undefined : kind); +} + +export async function search(a: Account, query: string, kind: "all" | Kind, signal?: AbortSignal): Promise { + const q = encodeURIComponent(query.trim()); + const path = kind === "all" ? "multi" : kind; + const r = await tmdb<{ results?: TmdbResult[] }>( + a, + `/search/${path}?query=${q}&include_adult=false&language=${a.language}&page=1`, + signal, + ); + return page(r, kind === "all" ? undefined : kind); +} + +const memory = new Map(); +const DAY = 86400_000; + +/** Runtime, cast, trailer and providers; kept a day. */ +export async function details(a: Account, t: Pick, signal?: AbortSignal): Promise { + const key = `details:${t.kind}:${t.id}:${a.language}:${a.region}`; + const hit = memory.get(key) ?? + await load<{ at: number; title: Title }>(key).then((c) => c && Date.now() - c.at < DAY ? c.title : undefined); + if (hit) { + memory.set(key, hit); + return hit; + } + const append = t.kind === "movie" + ? "credits,videos,watch/providers,release_dates" + : "aggregate_credits,videos,watch/providers,content_ratings,external_ids"; + const lang = a.language.slice(0, 2); + const d = await tmdb<TmdbDetails>( + a, + `/${t.kind}/${t.id}?language=${a.language}&append_to_response=${append}&include_video_language=${lang},en,null`, + signal, + ); + const title = fromDetails(d, t.kind, a.region); + memory.set(key, title); + await save(key, { at: Date.now(), title }); + return title; +} + +/** Details for each title, in parallel; a title whose details fail is kept as it was. */ +export function withDetails(a: Account, titles: Title[], signal: AbortSignal, count = 12): Promise<Title[]> { + return Promise.all(titles.map((t, i) => + i < count + ? details(a, t, signal).catch((e) => { + if ((e as Error).name === "AbortError" || e instanceof AuthError) throw e; + return t; + }) + : Promise.resolve(t) + )); +} + +// --------------------------------------------------------------------------- +// Watchlist + +export interface Saved { + title: Title; + added: number; +} + +export const watchlist = async () => await load<Saved[]>("watchlist") ?? []; +export const watchKey = (t: Pick<Title, "kind" | "id">) => `${t.kind}:${t.id}`; + +export async function toggleWatch(t: Title): Promise<boolean> { + const list = await watchlist(); + const there = list.some((s) => watchKey(s.title) === watchKey(t)); + // Kept small: the list view shows what was saved, details are reloaded. + const slim: Title = { ...t, cast: undefined, watch: undefined }; + await save( + "watchlist", + there ? list.filter((s) => watchKey(s.title) !== watchKey(t)) : [{ title: slim, added: Date.now() }, ...list], + ); + return !there; +} + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free with a TMDB account", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect TMDB", + "", + "Movies and shows come from **The Movie Database**, free for personal use:", + "", + "1. Sign in or create an account on themoviedb.org.", + "2. Open **Settings → API** (Enter opens it) and request an API key for personal use.", + "3. Copy the **API Read Access Token** (the long one; the shorter API key works too).", + "4. Paste it in **Settings → Extensions → Movies & TV → Configure**, and set your region", + " (`US`, `GB`, `FR`…) for streaming services.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Get a TMDB Token", { icon: Icons.key })], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + throw e; + } +} diff --git a/extensions/movies/backslash.toml b/extensions/movies/backslash.toml new file mode 100644 index 0000000..74ccd11 --- /dev/null +++ b/extensions/movies/backslash.toml @@ -0,0 +1,53 @@ +[extension] +id = "movies" +title = "Movies & TV" +description = "Search movies and TV shows on TMDB with posters, ratings, cast, trailers and where to stream them, and keep a watchlist" +version = "1.0.0" +author = "Backslash" +icon = "bs-film" +license = "MIT" + +# The TMDB API, and image.tmdb.org where it serves posters. +[permissions] +net = ["api.themoviedb.org", "image.tmdb.org"] + +[[commands]] +name = "search" +title = "Search Movies & TV" +description = "Trending this week, or type to search; posters, cast, trailers and streaming services" +icon = "bs-film" +keywords = [ + "movie", + "movies", + "film", + "tv", + "show", + "series", + "tmdb", + "imdb", + "trailer", + "watchlist", + "cinéma", + "série", + "séries", + "bande-annonce", + "à voir", +] + +[[preferences]] +name = "token" +title = "TMDB API Read Access Token" +description = "themoviedb.org → Settings → API: the API Read Access Token (or the shorter API key)" +type = "secret" + +[[preferences]] +name = "region" +title = "Region" +description = "Two-letter country code for streaming services and release dates: US, GB, FR, DE, CA…" +default = "US" + +[[preferences]] +name = "language" +title = "Language" +description = "Language of titles and overviews: en-US, fr-FR, de-DE, es-ES…" +default = "en-US" diff --git a/extensions/movies/commands/search.ts b/extensions/movies/commands/search.ts new file mode 100644 index 0000000..b6ead46 --- /dev/null +++ b/extensions/movies/commands/search.ts @@ -0,0 +1,55 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { type Account, guarded, search, token, trending, watchKey, watchlist, withDetails } from "../api.ts"; +import { type Kind, matches, normalizeLanguage, normalizeRegion, type Title } from "../lib.ts"; +import { titleItem } from "../views.ts"; + +type View = "all" | "movie" | "tv" | "watchlist"; + +export default (ctx: Context) => { + const account = (): Account => ({ + token: token(ctx.preferences), + region: normalizeRegion(ctx.preferences.region), + language: normalizeLanguage(ctx.preferences.language), + }); + return list({ + placeholder: "Search movies and TV shows…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "movie", title: "Movies" }, + { value: "tv", title: "TV Shows" }, + { value: "watchlist", title: "Watchlist" }, + ], + }, + empty: { title: "Nothing found", description: "Try another title, or the original title", icon: Icons.film }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const n = items.length; + return `${n} title${n === 1 ? "" : "s"} · ${normalizeRegion(ctx.preferences.region)} · TMDB`; + }, + search: (query, { signal, filter }) => + guarded(async (): Promise<Item[]> => { + const a = account(); + const view = (filter ?? "all") as View; + const saved = await watchlist(); + const watched = new Set(saved.map((s) => watchKey(s.title))); + const item = (t: Title, section?: string) => + titleItem(t, { watched: watched.has(watchKey(t)), region: a.region, section }); + + if (view === "watchlist") { + const mine = saved.map((s) => s.title).filter((t) => matches(t, query)); + return (await withDetails(a, mine, signal, 30)).map((t) => item(t, "Watchlist")); + } + const kind: "all" | Kind = view === "movie" || view === "tv" ? view : "all"; + if (query.trim()) { + return (await withDetails(a, await search(a, query, kind, signal), signal)).map((t) => item(t)); + } + const found = await withDetails(a, await trending(a, kind, signal), signal, 20); + const mine = saved.map((s) => s.title).filter((t) => kind === "all" || t.kind === kind).slice(0, 5); + return [ + ...(await withDetails(a, mine, signal)).map((t) => item(t, "Watchlist")), + ...found.map((t) => item(t, "Trending This Week")), + ]; + }), + }); +}; diff --git a/extensions/movies/lib.ts b/extensions/movies/lib.ts new file mode 100644 index 0000000..6751742 --- /dev/null +++ b/extensions/movies/lib.ts @@ -0,0 +1,328 @@ +// TMDB answers → one compact `Title` shape, and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// TMDB shapes (the fields we read) + +/** A result of `/search/multi` or `/trending/all/week`. */ +export interface TmdbResult { + id: number; + media_type?: "movie" | "tv" | "person" | string; + title?: string; + name?: string; + original_title?: string; + original_name?: string; + release_date?: string; + first_air_date?: string; + poster_path?: string | null; + backdrop_path?: string | null; + vote_average?: number; + vote_count?: number; + overview?: string; + genre_ids?: number[]; + original_language?: string; + popularity?: number; + adult?: boolean; +} + +interface Provider { + provider_id: number; + provider_name: string; + logo_path?: string; + display_priority?: number; +} + +interface RegionProviders { + link?: string; + flatrate?: Provider[]; + free?: Provider[]; + ads?: Provider[]; + rent?: Provider[]; + buy?: Provider[]; +} + +interface Video { + key: string; + name?: string; + site: string; + type: string; + official?: boolean; + published_at?: string; + iso_639_1?: string; +} + +/** `/movie/{id}` or `/tv/{id}` with `append_to_response=credits,videos,watch/providers,…`. */ +export interface TmdbDetails extends TmdbResult { + genres?: { id: number; name: string }[]; + runtime?: number | null; + episode_run_time?: number[]; + number_of_seasons?: number; + number_of_episodes?: number; + tagline?: string; + status?: string; + imdb_id?: string | null; + homepage?: string | null; + created_by?: { name: string }[]; + networks?: { name: string }[]; + last_air_date?: string | null; + next_episode_to_air?: { air_date?: string; season_number?: number; episode_number?: number; name?: string } | null; + credits?: { cast?: { name: string; character?: string; order?: number }[]; crew?: { name: string; job: string }[] }; + aggregate_credits?: { cast?: { name: string; roles?: { character?: string }[]; order?: number }[] }; + videos?: { results?: Video[] }; + "watch/providers"?: { results?: Record<string, RegionProviders> }; + external_ids?: { imdb_id?: string | null }; + release_dates?: { results?: { iso_3166_1: string; release_dates: { certification?: string; type?: number }[] }[] }; + content_ratings?: { results?: { iso_3166_1: string; rating?: string }[] }; +} + +// --------------------------------------------------------------------------- +// Our shape + +export type Kind = "movie" | "tv"; + +export interface Watch { + link?: string; + stream: string[]; + free: string[]; + rent: string[]; + buy: string[]; +} + +export interface Title { + kind: Kind; + id: number; + title: string; + originalTitle?: string; + date?: string; + year?: string; + poster?: string; + rating?: number; + votes?: number; + overview?: string; + genres: string[]; + language?: string; + /** Present once the details were loaded. */ + full?: boolean; + tagline?: string; + status?: string; + runtime?: number; + seasons?: number; + episodes?: number; + certification?: string; + directors?: string[]; + cast?: { name: string; character?: string }[]; + trailer?: { key: string; name?: string }; + watch?: Watch; + imdb?: string; + homepage?: string; + network?: string; + nextEpisode?: string; +} + +export const MOVIE_GENRES: Record<number, string> = { + 28: "Action", + 12: "Adventure", + 16: "Animation", + 35: "Comedy", + 80: "Crime", + 99: "Documentary", + 18: "Drama", + 10751: "Family", + 14: "Fantasy", + 36: "History", + 27: "Horror", + 10402: "Music", + 9648: "Mystery", + 10749: "Romance", + 878: "Science Fiction", + 10770: "TV Movie", + 53: "Thriller", + 10752: "War", + 37: "Western", +}; + +export const TV_GENRES: Record<number, string> = { + 10759: "Action & Adventure", + 16: "Animation", + 35: "Comedy", + 80: "Crime", + 99: "Documentary", + 18: "Drama", + 10751: "Family", + 10762: "Kids", + 9648: "Mystery", + 10763: "News", + 10764: "Reality", + 10765: "Sci-Fi & Fantasy", + 10766: "Soap", + 10767: "Talk", + 10768: "War & Politics", + 37: "Western", +}; + +export const IMAGE = "https://image.tmdb.org/t/p"; +export const poster = (path: string | undefined, size: "w92" | "w185" | "w342" | "w500") => + path ? `${IMAGE}/${size}${path}` : undefined; + +/** A search or trending result; undefined for people and nameless entries. */ +export function fromResult(r: TmdbResult, kind?: Kind): Title | undefined { + const k = (kind ?? r.media_type) as Kind; + if (k !== "movie" && k !== "tv") return undefined; + const title = (k === "movie" ? r.title : r.name) ?? r.title ?? r.name; + if (!title) return undefined; + const original = k === "movie" ? r.original_title : r.original_name; + const date = (k === "movie" ? r.release_date : r.first_air_date) || undefined; + const genres = k === "movie" ? MOVIE_GENRES : TV_GENRES; + return { + kind: k, + id: r.id, + title, + originalTitle: original && original !== title ? original : undefined, + date, + year: date?.slice(0, 4), + poster: r.poster_path ?? undefined, + rating: r.vote_count ? r.vote_average : undefined, + votes: r.vote_count, + overview: r.overview || undefined, + genres: (r.genre_ids ?? []).map((g) => genres[g]).filter(Boolean), + language: r.original_language, + }; +} + +/** Prefers an official YouTube trailer, then a teaser, then any YouTube video. */ +export function pickTrailer(videos: Video[] | undefined): { key: string; name?: string } | undefined { + const yt = (videos ?? []).filter((v) => v.site === "YouTube" && v.key); + const rank = (v: Video) => (v.type === "Trailer" ? 0 : v.type === "Teaser" ? 2 : 4) + (v.official ? 0 : 1); + const best = + [...yt].sort((a, b) => rank(a) - rank(b) || (b.published_at ?? "").localeCompare(a.published_at ?? ""))[0]; + return best ? { key: best.key, name: best.name } : undefined; +} + +const names = (list: Provider[] | undefined) => + [...(list ?? [])].sort((a, b) => (a.display_priority ?? 99) - (b.display_priority ?? 99)).map((p) => p.provider_name); + +export function watchIn(details: TmdbDetails, region: string): Watch | undefined { + const r = details["watch/providers"]?.results?.[region]; + if (!r) return undefined; + return { + link: r.link, + stream: names(r.flatrate), + free: [...new Set([...names(r.free), ...names(r.ads)])], + rent: names(r.rent), + buy: names(r.buy), + }; +} + +/** A title with its details: runtime, cast, trailer, where to watch in `region`. */ +export function fromDetails(d: TmdbDetails, kind: Kind, region: string): Title { + const base = fromResult({ ...d, genre_ids: [] }, kind)!; + const cast = kind === "tv" && d.aggregate_credits?.cast?.length + ? d.aggregate_credits.cast.map((c) => ({ name: c.name, character: c.roles?.[0]?.character, order: c.order })) + : (d.credits?.cast ?? []); + const runtime = kind === "movie" ? d.runtime ?? undefined : d.episode_run_time?.[0]; + const certification = kind === "movie" + ? d.release_dates?.results?.find((r) => r.iso_3166_1 === region)?.release_dates.map((x) => x.certification) + .find(Boolean) + : d.content_ratings?.results?.find((r) => r.iso_3166_1 === region)?.rating; + const next = d.next_episode_to_air; + return { + ...base, + full: true, + genres: (d.genres ?? []).map((g) => g.name), + tagline: d.tagline || undefined, + status: d.status || undefined, + runtime: runtime || undefined, + seasons: d.number_of_seasons, + episodes: d.number_of_episodes, + certification: certification || undefined, + directors: kind === "movie" + ? (d.credits?.crew ?? []).filter((c) => c.job === "Director").map((c) => c.name) + : (d.created_by ?? []).map((c) => c.name), + cast: [...cast].sort((a, b) => (a.order ?? 99) - (b.order ?? 99)).slice(0, 5).map((c) => ({ + name: c.name, + character: c.character || undefined, + })), + trailer: pickTrailer(d.videos?.results), + watch: watchIn(d, region), + imdb: d.imdb_id ?? d.external_ids?.imdb_id ?? undefined, + homepage: d.homepage || undefined, + network: d.networks?.[0]?.name, + nextEpisode: next?.air_date + ? `S${next.season_number}E${next.episode_number}${next.name ? ` “${next.name}”` : ""} · ${ + longDate(next.air_date) + }` + : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** 155 → "2 h 35 min". */ +export function duration(minutes: number | undefined): string | undefined { + if (!minutes) return undefined; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return h ? `${h} h${m ? ` ${String(m).padStart(2, "0")} min` : ""}` : `${m} min`; +} + +export const rating = (r: number | undefined) => (r === undefined ? "—" : r.toFixed(1)); + +export function ratingColor(r: number | undefined): Color | undefined { + if (r === undefined) return undefined; + return r >= 7.5 ? "green" : r >= 6 ? "yellow" : "red"; +} + +/** "2024-02-27" → "27 Feb 2024". */ +export function longDate(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(`${iso}T00:00:00Z`); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); +} + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +/** "Movie · 2024 · 2 h 46 min" or "TV · 2019 · 4 seasons". */ +export function summaryLine(t: Title): string { + return [ + t.kind === "movie" ? "Movie" : "TV", + t.year, + t.kind === "movie" ? duration(t.runtime) : t.seasons ? plural(t.seasons, "season") : undefined, + ].filter(Boolean).join(" · "); +} + +export const tmdbUrl = (t: Pick<Title, "kind" | "id">) => `https://www.themoviedb.org/${t.kind}/${t.id}`; +export const youtubeUrl = (key: string) => `https://www.youtube.com/watch?v=${encodeURIComponent(key)}`; +export const imdbUrl = (id: string) => `https://www.imdb.com/title/${encodeURIComponent(id)}/`; + +/** v4 read access tokens are JWTs; v3 keys are 32 hex characters. */ +export function auth(token: string): { headers: Record<string, string>; param?: string } { + const t = token.trim(); + if (/^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(t)) return { headers: { Authorization: `Bearer ${t}` } }; + return { headers: {}, param: `api_key=${encodeURIComponent(t)}` }; +} + +/** `"fr"` → `"FR"`; anything else than two letters → `"US"`. */ +export function normalizeRegion(input: unknown): string { + const r = String(input ?? "").trim().toUpperCase(); + return /^[A-Z]{2}$/.test(r) ? r : "US"; +} + +/** `"fr"` → `"fr-FR"`, `"pt-br"` → `"pt-BR"`; default en-US. */ +export function normalizeLanguage(input: unknown): string { + const l = String(input ?? "").trim(); + const m = /^([a-z]{2})(?:[-_]([a-z]{2}))?$/i.exec(l); + if (!m) return "en-US"; + const lang = m[1].toLowerCase(); + return `${lang}-${(m[2] ?? (lang === "en" ? "US" : lang)).toUpperCase()}`; +} + +/** Watchlist entries match the query on titles, original titles and genres. */ +export function matches(t: Title, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [t.title, t.originalTitle ?? "", t.year ?? "", ...t.genres].some((s) => s.toLowerCase().includes(q)); +} diff --git a/extensions/movies/lib_test.ts b/extensions/movies/lib_test.ts new file mode 100644 index 0000000..d09ad4f --- /dev/null +++ b/extensions/movies/lib_test.ts @@ -0,0 +1,239 @@ +import { assertEquals } from "@std/assert"; +import { + auth, + duration, + fromDetails, + fromResult, + longDate, + matches, + normalizeLanguage, + normalizeRegion, + pickTrailer, + poster, + ratingColor, + summaryLine, + type TmdbDetails, + type TmdbResult, + tmdbUrl, +} from "./lib.ts"; +import { titleItem } from "./views.ts"; + +// Shaped like `/trending/all/week` and `/search/multi` results. +const dune: TmdbResult = { + adult: false, + id: 693134, + title: "Dune: Part Two", + original_title: "Dune: Part Two", + overview: "Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen.", + poster_path: "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", + media_type: "movie", + original_language: "en", + genre_ids: [878, 12], + release_date: "2024-02-27", + vote_average: 8.153, + vote_count: 6234, +}; +const amelie: TmdbResult = { + id: 194, + title: "Amélie", + original_title: "Le Fabuleux Destin d'Amélie Poulain", + media_type: "movie", + poster_path: null, + release_date: "2001-04-25", + vote_average: 7.9, + vote_count: 11000, + genre_ids: [35, 10749], + original_language: "fr", + overview: "", +}; +const person: TmdbResult = { id: 1190668, name: "Timothée Chalamet", media_type: "person" }; + +// `/movie/693134?append_to_response=credits,videos,watch/providers,release_dates`. +const duneDetails: TmdbDetails = { + ...dune, + genre_ids: undefined, + genres: [{ id: 878, name: "Science Fiction" }, { id: 12, name: "Adventure" }], + runtime: 167, + tagline: "Long live the fighters.", + status: "Released", + imdb_id: "tt15239678", + credits: { + cast: [ + { name: "Zendaya", character: "Chani", order: 1 }, + { name: "Timothée Chalamet", character: "Paul Atreides", order: 0 }, + { name: "Rebecca Ferguson", character: "Lady Jessica", order: 2 }, + { name: "Javier Bardem", character: "Stilgar", order: 3 }, + { name: "Josh Brolin", character: "Gurney Halleck", order: 4 }, + { name: "Austin Butler", character: "Feyd-Rautha", order: 5 }, + ], + crew: [{ name: "Denis Villeneuve", job: "Director" }, { name: "Hans Zimmer", job: "Original Music Composer" }], + }, + videos: { + results: [ + { key: "teaser", site: "YouTube", type: "Teaser", official: true, published_at: "2023-05-03T13:00:00.000Z" }, + { key: "fan", site: "YouTube", type: "Trailer", official: false }, + { key: "Way9Dexny3w", site: "YouTube", type: "Trailer", official: true, published_at: "2023-12-18T17:00:00Z" }, + { key: "vimeo", site: "Vimeo", type: "Trailer", official: true }, + ], + }, + "watch/providers": { + results: { + US: { + link: "https://www.themoviedb.org/movie/693134-dune-part-two/watch?locale=US", + flatrate: [{ provider_id: 1899, provider_name: "Max", display_priority: 2 }], + rent: [ + { provider_id: 2, provider_name: "Apple TV", display_priority: 4 }, + { provider_id: 10, provider_name: "Amazon Video", display_priority: 1 }, + ], + }, + }, + }, + release_dates: { + results: [{ + iso_3166_1: "US", + release_dates: [{ certification: "", type: 1 }, { certification: "PG-13", type: 3 }], + }], + }, +}; + +// `/tv/126308?append_to_response=aggregate_credits,…`. +const shogun: TmdbDetails = { + id: 126308, + name: "Shōgun", + first_air_date: "2024-02-27", + poster_path: "/7O4iVfOMQmdCSxhOg1WnzG1AgYT.jpg", + vote_average: 8.6, + vote_count: 1100, + genres: [{ id: 18, name: "Drama" }], + episode_run_time: [58], + number_of_seasons: 1, + number_of_episodes: 10, + created_by: [{ name: "Rachel Kondo" }, { name: "Justin Marks" }], + networks: [{ name: "FX" }], + next_episode_to_air: { air_date: "2026-11-12", season_number: 2, episode_number: 1, name: "Return" }, + aggregate_credits: { cast: [{ name: "Hiroyuki Sanada", roles: [{ character: "Yoshii Toranaga" }], order: 0 }] }, + content_ratings: { results: [{ iso_3166_1: "US", rating: "TV-MA" }] }, + external_ids: { imdb_id: "tt2788316" }, +}; + +Deno.test("results", () => { + const t = fromResult(dune)!; + assertEquals(t.kind, "movie"); + assertEquals(t.year, "2024"); + assertEquals(t.genres, ["Science Fiction", "Adventure"]); + assertEquals(t.originalTitle, undefined); + assertEquals(fromResult(person), undefined); + const a = fromResult(amelie)!; + assertEquals(a.originalTitle, "Le Fabuleux Destin d'Amélie Poulain"); + assertEquals(a.poster, undefined); + assertEquals(a.overview, undefined); + // `/trending/tv/week` results have no media_type. + assertEquals( + fromResult({ id: 1, name: "Severance", first_air_date: "2022-02-17", genre_ids: [18, 9648] }, "tv")?.genres, + [ + "Drama", + "Mystery", + ], + ); +}); + +Deno.test("movie details", () => { + const t = fromDetails(duneDetails, "movie", "US"); + assertEquals(t.full, true); + assertEquals(t.runtime, 167); + assertEquals(t.certification, "PG-13"); + assertEquals(t.directors, ["Denis Villeneuve"]); + assertEquals(t.cast?.map((c) => c.name), [ + "Timothée Chalamet", + "Zendaya", + "Rebecca Ferguson", + "Javier Bardem", + "Josh Brolin", + ]); + assertEquals(t.trailer?.key, "Way9Dexny3w"); + assertEquals(t.watch?.stream, ["Max"]); + assertEquals(t.watch?.rent, ["Amazon Video", "Apple TV"]); + assertEquals(t.imdb, "tt15239678"); + assertEquals(fromDetails(duneDetails, "movie", "FR").watch, undefined); + assertEquals(summaryLine(t), "Movie · 2024 · 2 h 47 min"); +}); + +Deno.test("show details", () => { + const t = fromDetails(shogun, "tv", "US"); + assertEquals(t.seasons, 1); + assertEquals(t.runtime, 58); + assertEquals(t.certification, "TV-MA"); + assertEquals(t.directors, ["Rachel Kondo", "Justin Marks"]); + assertEquals(t.cast, [{ name: "Hiroyuki Sanada", character: "Yoshii Toranaga" }]); + assertEquals(t.network, "FX"); + assertEquals(t.nextEpisode, "S2E1 “Return” · 12 Nov 2026"); + assertEquals(t.imdb, "tt2788316"); + assertEquals(t.trailer, undefined); + assertEquals(summaryLine(t), "TV · 2024 · 1 season"); +}); + +Deno.test("trailers", () => { + assertEquals(pickTrailer([{ key: "t", site: "YouTube", type: "Teaser" }])?.key, "t"); + assertEquals(pickTrailer([{ key: "v", site: "Vimeo", type: "Trailer" }]), undefined); + assertEquals(pickTrailer(undefined), undefined); +}); + +Deno.test("formatting", () => { + assertEquals(duration(167), "2 h 47 min"); + assertEquals(duration(120), "2 h"); + assertEquals(duration(45), "45 min"); + assertEquals(duration(65), "1 h 05 min"); + assertEquals(duration(undefined), undefined); + assertEquals(longDate("2024-02-27"), "27 Feb 2024"); + assertEquals(ratingColor(8.2), "green"); + assertEquals(ratingColor(6.4), "yellow"); + assertEquals(ratingColor(4), "red"); + assertEquals(poster("/a.jpg", "w342"), "https://image.tmdb.org/t/p/w342/a.jpg"); + assertEquals(poster(undefined, "w92"), undefined); + assertEquals(tmdbUrl({ kind: "tv", id: 1 }), "https://www.themoviedb.org/tv/1"); +}); + +Deno.test("preferences and auth", () => { + assertEquals(auth("eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJ4In0.c2ln"), { + headers: { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJ4In0.c2ln" }, + }); + assertEquals(auth(" 0123456789abcdef0123456789abcdef "), { + headers: {}, + param: "api_key=0123456789abcdef0123456789abcdef", + }); + assertEquals(normalizeRegion("fr"), "FR"); + assertEquals(normalizeRegion("France"), "US"); + assertEquals(normalizeLanguage("fr"), "fr-FR"); + assertEquals(normalizeLanguage("en"), "en-US"); + assertEquals(normalizeLanguage("pt_br"), "pt-BR"); + assertEquals(normalizeLanguage("klingon"), "en-US"); +}); + +Deno.test("watchlist matching", () => { + const a = fromResult(amelie)!; + assertEquals(matches(a, "fabuleux"), true); + assertEquals(matches(a, "romance"), true); + assertEquals(matches(a, "2001"), true); + assertEquals(matches(a, "dune"), false); + assertEquals(matches(a, ""), true); +}); + +Deno.test("title item", () => { + const item = titleItem(fromDetails(duneDetails, "movie", "US"), { watched: true, region: "US" }); + assertEquals(item.icon, "https://image.tmdb.org/t/p/w92/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg"); + assertEquals(item.detail?.image, "https://image.tmdb.org/t/p/w342/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg"); + assertEquals(item.detail?.stats?.map((s) => s.value), ["★ 8.2", "2024", "2 h 47 min"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open on TMDB", + "Watch Trailer on YouTube", + "Where to Watch in US", + "Remove from Watchlist", + ]); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text), [undefined, "Max", "8.2"]); + const cast = item.detail?.metadata?.filter((m) => m.avatar); + assertEquals(cast?.[1], { label: "Cast", value: "Timothée Chalamet as Paul Atreides", avatar: "Timothée Chalamet" }); + + const bare = titleItem(fromResult(amelie)!, { watched: false, region: "FR" }); + assertEquals(bare.icon, { source: "bs-film", tint: "purple" }); + assertEquals(bare.detail?.markdown, "No overview yet."); +}); diff --git a/extensions/movies/views.ts b/extensions/movies/views.ts new file mode 100644 index 0000000..d46d1a3 --- /dev/null +++ b/extensions/movies/views.ts @@ -0,0 +1,165 @@ +// Movies and shows as launcher items. + +import { + type Accessory, + type Action, + action, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + type Stat, + toast, +} from "@backslash/api"; +import { toggleWatch } from "./api.ts"; +import { + duration, + imdbUrl, + longDate, + plural, + poster, + rating, + ratingColor, + summaryLine, + type Title, + tmdbUrl, + youtubeUrl, +} from "./lib.ts"; + +const LANGUAGES = new Intl.DisplayNames(["en"], { type: "language" }); +const languageName = (code: string | undefined) => { + if (!code) return undefined; + try { + return LANGUAGES.of(code); + } catch { + return code; + } +}; + +export function titleMetadata(t: Title, region: string): Metadata[] { + const lines: Metadata[] = []; + if (t.genres.length) lines.push(meta.tags("Genres", t.genres.map((g) => ({ text: g, color: "purple" as const })))); + if (t.date) { + lines.push(meta.text(t.kind === "movie" ? "Released" : "First aired", longDate(t.date), { icon: Icons.calendar })); + } + if (t.kind === "movie" && t.runtime) lines.push(meta.text("Runtime", duration(t.runtime)!, { icon: Icons.clock })); + if (t.kind === "tv" && t.seasons) { + lines.push( + meta.text( + "Seasons", + `${plural(t.seasons, "season")}${t.episodes ? ` · ${plural(t.episodes, "episode")}` : ""}${ + t.runtime ? ` · ${t.runtime} min` : "" + }`, + { icon: Icons.layers }, + ), + ); + } + if (t.nextEpisode) lines.push(meta.text("Next episode", t.nextEpisode, { icon: Icons.calendar })); + if (t.certification) lines.push(meta.tags(`Rated (${region})`, [t.certification])); + if (t.directors?.length) { + lines.push(meta.person(t.kind === "movie" ? "Director" : "Created by", t.directors.slice(0, 3).join(", "))); + } + if (t.cast?.length) { + lines.push(meta.separator()); + t.cast.forEach((c, i) => + lines.push(meta.person(i ? "" : "Cast", c.character ? `${c.name} as ${c.character}` : c.name, c.name)) + ); + } + if (t.watch) { + lines.push(meta.separator()); + const groups: [string, string[], "green" | "teal" | "blue" | "gray"][] = [ + ["Stream", t.watch.stream, "green"], + ["Free", t.watch.free, "teal"], + ["Rent", t.watch.rent, "blue"], + ["Buy", t.watch.buy, "gray"], + ]; + for (const [label, names, color] of groups) { + if (names.length) lines.push(meta.tags(label, names.slice(0, 5).map((text) => ({ text, color })))); + } + if (!groups.some(([, names]) => names.length)) lines.push(meta.text("Where to watch", `Nowhere in ${region}`)); + } else if (t.full) { + lines.push(meta.text("Where to watch", `No streaming service in ${region}`, { icon: Icons.eyeOff })); + } + lines.push(meta.separator()); + if (t.network) lines.push(meta.text("Network", t.network, { icon: Icons.monitor })); + if (t.status) lines.push(meta.text("Status", t.status)); + if (t.originalTitle) lines.push(meta.text("Original title", t.originalTitle)); + const lang = languageName(t.language); + if (lang) lines.push(meta.text("Language", lang, { icon: Icons.globe })); + lines.push(meta.link("TMDB", `themoviedb.org/${t.kind}/${t.id}`, tmdbUrl(t))); + if (t.imdb) lines.push(meta.link("IMDb", t.imdb, imdbUrl(t.imdb))); + return lines; +} + +export function titleItem(t: Title, o: { watched: boolean; region: string; section?: string }): Item { + const stats: Stat[] = []; + if (t.rating !== undefined) { + stats.push({ label: "Rating", value: `★ ${rating(t.rating)}`, color: ratingColor(t.rating) }); + } + if (t.year) stats.push({ label: "Year", value: t.year }); + if (t.kind === "movie" && t.runtime) stats.push({ label: "Runtime", value: duration(t.runtime)! }); + if (t.kind === "tv" && t.seasons) stats.push({ label: "Seasons", value: t.seasons }); + + const accessories: Accessory[] = []; + if (o.watched && o.section !== "Watchlist") { + accessories.push({ icon: { source: Icons.bookmark, tint: "yellow" }, tooltip: "In your watchlist" }); + } + if (t.watch?.stream.length) { + accessories.push({ tag: t.watch.stream[0], color: "green", tooltip: `Streaming on ${t.watch.stream.join(", ")}` }); + } + if (t.rating !== undefined) { + accessories.push({ + text: rating(t.rating), + icon: { source: Icons.starFilled, tint: ratingColor(t.rating) }, + tooltip: `${rating(t.rating)}/10 from ${t.votes ?? 0} votes`, + }); + } + + const actions: Action[] = [open(tmdbUrl(t), "Open on TMDB")]; + if (t.trailer) { + actions.push( + open(youtubeUrl(t.trailer.key), "Watch Trailer on YouTube", { icon: Icons.play, shortcut: "<Ctrl>t" }), + ); + } + if (t.watch?.link) { + actions.push(open(t.watch.link, `Where to Watch in ${o.region}`, { icon: Icons.monitor, shortcut: "<Ctrl>w" })); + } + actions.push( + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const now = await toggleWatch(t); + await toast(`${t.title} ${now ? "added to" : "removed from"} the watchlist`, { style: "success" }); + return refresh(); + }, { icon: o.watched ? Icons.bookmark : Icons.plus, shortcut: "<Ctrl>s" }), + ); + if (t.imdb) actions.push(open(imdbUrl(t.imdb), "Open on IMDb", { section: "Open" })); + if (t.homepage) actions.push(open(t.homepage, "Open Official Website", { section: "Open" })); + actions.push( + copy(tmdbUrl(t), "Copy TMDB Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(t.year ? `${t.title} (${t.year})` : t.title, "Copy Title", { section: "Copy" }), + ...(t.trailer ? [copy(youtubeUrl(t.trailer.key), "Copy Trailer Link", { section: "Copy" })] : []), + ); + + const md = [ + ...(t.tagline ? [`*${t.tagline}*`, ""] : []), + t.overview ?? "No overview yet.", + ]; + return { + id: `${o.section ?? ""}:${t.kind}:${t.id}`, + title: t.title, + subtitle: summaryLine(t), + section: o.section, + icon: poster(t.poster, "w92") ?? { source: t.kind === "movie" ? Icons.film : Icons.monitor, tint: "purple" }, + keywords: [...t.genres, t.originalTitle ?? "", t.kind === "movie" ? "movie" : "tv show series"], + accessories, + detail: { + image: poster(t.poster, "w342"), + stats: stats.length ? stats : undefined, + markdown: md.join("\n"), + metadata: titleMetadata(t, o.region), + }, + actions, + }; +} diff --git a/extensions/mpd/backslash.toml b/extensions/mpd/backslash.toml new file mode 100644 index 0000000..04253a2 --- /dev/null +++ b/extensions/mpd/backslash.toml @@ -0,0 +1,74 @@ +[extension] +id = "mpd" +title = "MPD Music" +description = "Control the Music Player Daemon: now playing with covers, queue, library search and playlists" +version = "1.0.0" +author = "Backslash" +icon = "bs-music" +license = "MIT" + +# The MPD protocol over TCP on localhost:6600, no program needed. Album +# covers (MPD's albumart / readpicture) are cached in ~/.cache/backslash/mpd. +# systemctl starts mpd.service when it isn't running. +[permissions] +net = ["localhost:6600", "127.0.0.1:6600"] +run = ["systemctl"] +read = ["~/.cache/backslash/mpd", "/etc/os-release"] +write = ["~/.cache/backslash/mpd"] +env = ["HOME"] + +[[commands]] +name = "mpd" +title = "MPD Music" +description = "Now playing, controls and the queue" +icon = "bs-music" +keywords = ["mpd", "music", "musique", "now playing", "queue", "file d'attente", "player", "lecteur", "song", "chanson"] + +[[commands]] +name = "search" +title = "Search Music Library" +description = "Songs, albums and artists in MPD's library: play now, next or later" +icon = "bs-search" +keywords = ["mpd", "music", "musique", "search", "chercher", "library", "bibliothèque", "album", "artist", "artiste"] + +[[commands]] +name = "playlists" +title = "MPD Playlists" +description = "Play, queue or delete saved playlists" +icon = "bs-layers" +keywords = ["mpd", "playlist", "playlists", "listes de lecture", "music", "musique"] + +[[commands]] +name = "play-pause" +title = "MPD Play / Pause" +description = "Toggles playback" +icon = "bs-play" +mode = "no-view" +keywords = ["mpd", "play", "pause", "lecture", "music"] + +[[commands]] +name = "next" +title = "MPD Next Song" +icon = "bs-next" +mode = "no-view" +keywords = ["mpd", "next", "skip", "suivant", "music"] + +[[commands]] +name = "previous" +title = "MPD Previous Song" +icon = "bs-previous" +mode = "no-view" +keywords = ["mpd", "previous", "back", "précédent", "music"] + +[[preferences]] +name = "password" +title = "Password" +description = "Only if MPD's configuration sets a password" +type = "secret" + +[[preferences]] +name = "covers" +title = "Show album covers" +description = "Reads covers from MPD (cover files in album folders, or embedded pictures)" +type = "boolean" +default = true diff --git a/extensions/mpd/client.ts b/extensions/mpd/client.ts new file mode 100644 index 0000000..b983325 --- /dev/null +++ b/extensions/mpd/client.ts @@ -0,0 +1,165 @@ +// A small MPD client over TCP (localhost:6600): text commands, and the binary +// chunks of albumart / readpicture. + +import { ACK_NO_EXIST, parseAck, quote } from "./lib.ts"; + +export const HOST = "localhost"; +export const PORT = 6600; + +export class MpdError extends Error { + constructor(message: string, public code?: number) { + super(message); + } +} + +/** MPD isn't running, or doesn't listen on localhost:6600. */ +export class Unreachable extends Error {} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export class Mpd { + private buf = new Uint8Array(0); + version = ""; + + private constructor(private conn: Deno.Conn) {} + + static async connect(password?: string): Promise<Mpd> { + let conn: Deno.Conn | undefined; + let error: unknown; + // IPv4 first: "localhost" may resolve to ::1 while MPD only listens on 127.0.0.1. + for (const hostname of ["127.0.0.1", HOST]) { + try { + conn = await Deno.connect({ hostname, port: PORT }); + break; + } catch (e) { + error = e; + } + } + if (!conn) { + if (error instanceof Deno.errors.ConnectionRefused || error instanceof Deno.errors.NotFound) { + throw new Unreachable(`MPD isn't running on ${HOST}:${PORT}`); + } + throw new Unreachable(`Can't reach MPD on ${HOST}:${PORT}: ${(error as Error).message}`); + } + const mpd = new Mpd(conn); + const hello = await mpd.line(); + if (!hello.startsWith("OK MPD ")) { + conn.close(); + throw new Unreachable(`Something else listens on ${HOST}:${PORT}`); + } + mpd.version = hello.slice(7).trim(); + if (password) await mpd.command(`password ${quote(password)}`); + return mpd; + } + + close() { + try { + this.conn.close(); + } catch { + // Already closed. + } + } + + private async fill(): Promise<void> { + const chunk = new Uint8Array(64 * 1024); + const n = await this.conn.read(chunk); + if (n === null) throw new MpdError("MPD closed the connection"); + const next = new Uint8Array(this.buf.length + n); + next.set(this.buf); + next.set(chunk.subarray(0, n), this.buf.length); + this.buf = next; + } + + private async line(): Promise<string> { + for (;;) { + const i = this.buf.indexOf(10); + if (i >= 0) { + const line = decoder.decode(this.buf.subarray(0, i)); + this.buf = this.buf.subarray(i + 1); + return line; + } + await this.fill(); + } + } + + private async bytes(n: number): Promise<Uint8Array> { + while (this.buf.length < n) await this.fill(); + const out = this.buf.slice(0, n); + this.buf = this.buf.subarray(n); + return out; + } + + private send(cmd: string) { + return this.conn.write(encoder.encode(`${cmd}\n`)); + } + + /** Runs a command; returns its "key: value" lines. Throws MpdError on ACK. */ + async command(cmd: string): Promise<string> { + await this.send(cmd); + const lines: string[] = []; + for (;;) { + const line = await this.line(); + if (line === "OK") return lines.join("\n"); + if (line.startsWith("ACK ")) { + const ack = parseAck(line); + throw new MpdError(ack?.message || line, ack?.code); + } + lines.push(line); + } + } + + /** Several commands at once (command_list_begin … end); one round trip. */ + async commands(cmds: string[]): Promise<void> { + await this.command(["command_list_begin", ...cmds, "command_list_end"].join("\n")); + } + + /** A binary answer, read chunk by chunk: `albumart` (cover file) or `readpicture` (embedded). */ + async picture(kind: "albumart" | "readpicture", uri: string, max = 8 * 1024 * 1024): Promise<Uint8Array | undefined> { + const parts: Uint8Array[] = []; + let offset = 0; + let size = Infinity; + while (offset < size && offset < max) { + await this.send(`${kind} ${quote(uri)} ${offset}`); + let binary = -1; + for (;;) { + const line = await this.line(); + if (line.startsWith("ACK ")) { + const ack = parseAck(line); + if (ack?.code === ACK_NO_EXIST || /unknown command/i.test(ack?.message ?? "")) return undefined; + throw new MpdError(ack?.message || line, ack?.code); + } + if (line === "OK") break; + const [k, v] = [line.slice(0, line.indexOf(": ")), line.slice(line.indexOf(": ") + 2)]; + if (k === "size") size = Number(v); + if (k === "binary") { + binary = Number(v); + const data = await this.bytes(binary); + parts.push(data); + offset += data.length; + await this.bytes(1); // the newline after the data + } + } + // readpicture answers nothing at all when there is no picture. + if (binary <= 0) break; + } + if (!parts.length) return undefined; + const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; + } +} + +/** Connects, runs `fn`, closes. */ +export async function withMpd<T>(password: string | undefined, fn: (mpd: Mpd) => Promise<T>): Promise<T> { + const mpd = await Mpd.connect(password); + try { + return await fn(mpd); + } finally { + mpd.close(); + } +} diff --git a/extensions/mpd/commands/mpd.ts b/extensions/mpd/commands/mpd.ts new file mode 100644 index 0000000..d140701 --- /dev/null +++ b/extensions/mpd/commands/mpd.ts @@ -0,0 +1,165 @@ +import { type Context, copy, Icons, type Item, list, meta, updateCommandMetadata } from "@backslash/api"; +import type { Mpd } from "../client.ts"; +import { audioFormat, clock, type Song, songLine, type Status } from "../lib.ts"; +import { cover, type Prefs, queue, run, snapshot } from "../music.ts"; +import { act, errorItem, libraryActions, modeTags, playerActions, prefsOf, queueActions, songMetadata } from "../ui.ts"; + +let status = "MPD"; + +function nowPlaying(st: Status, song: Song | undefined, art: string | undefined, prefs: Prefs): Item { + const playing = st.state === "play"; + const where = st.duration + ? `${clock(st.elapsed)} / ${clock(st.duration)}` + : st.elapsed !== undefined + ? clock(st.elapsed) + : undefined; + const actions = [ + ...playerActions(st, prefs), + ...(song ? [copy(songLine(song), "Copy Song Name", { section: "Copy", shortcut: "<Ctrl><Shift>c" })] : []), + ...queueActions(st, prefs), + ]; + if (!song) { + return { + id: "now", + title: st.playlistLength ? "Stopped" : "Nothing to play", + subtitle: st.playlistLength ? `${st.playlistLength} songs in the queue` : "Search the library to add songs", + icon: { source: Icons.music, tint: "gray" }, + section: "Now Playing", + detail: { + metadata: [modeTags(st), ...(st.volume !== undefined ? [meta.progress("Volume", st.volume / 100)] : [])], + }, + actions, + }; + } + return { + id: "now", + title: song.title, + subtitle: [song.artist, song.album].filter(Boolean).join(" · ") || undefined, + icon: art ?? { source: song.stream ? Icons.radio : Icons.music, tint: playing ? "green" : "gray" }, + section: "Now Playing", + accessories: [ + ...(where ? [{ text: where }] : []), + { icon: { source: playing ? Icons.play : Icons.pause, tint: playing ? "green" as const : "gray" as const } }, + ], + detail: { + image: art, + metadata: songMetadata(song, [ + ...(st.duration ? [meta.progress("Position", (st.elapsed ?? 0) / st.duration, where)] : []), + meta.text("State", playing ? "Playing" : st.state === "pause" ? "Paused" : "Stopped", { + icon: playing ? Icons.play : Icons.pause, + color: playing ? "green" : undefined, + }), + ...(st.volume !== undefined ? [meta.progress("Volume", st.volume / 100)] : []), + modeTags(st), + ...(audioFormat(st.audio) + ? [meta.text("Format", `${audioFormat(st.audio)}${st.bitrate ? ` · ${st.bitrate} kbps` : ""}`)] + : []), + ...(st.error ? [meta.text("Error", st.error, { icon: Icons.alert, color: "red" })] : []), + ]), + }, + actions, + }; +} + +function queueItem(s: Song, st: Status, prefs: Prefs): Item { + const current = s.id === st.songId; + const next = s.pos === st.nextSong; + return { + id: `queue:${s.id}`, + title: s.title, + subtitle: [s.artist, s.album].filter(Boolean).join(" · ") || undefined, + icon: current + ? { source: st.state === "play" ? Icons.play : Icons.pause, tint: "green" } + : { source: s.stream ? Icons.radio : Icons.music, tint: "gray" }, + section: `Queue`, + keywords: [s.artist ?? "", s.album ?? ""], + accessories: [ + ...(current ? [{ tag: "Now", color: "green" as const }] : next ? [{ tag: "Next", color: "blue" as const }] : []), + ...(s.duration ? [{ text: clock(s.duration) }] : []), + ], + detail: { + metadata: songMetadata(s, [meta.text("Position in queue", `${(s.pos ?? 0) + 1} of ${st.playlistLength}`)]), + }, + actions: [ + act( + prefs, + current && st.state === "play" ? "Pause" : "Play This Song", + (m: Mpd) => m.command(current && st.state === "play" ? "pause 1" : `playid ${s.id}`), + { icon: Icons.play }, + ), + ...(st.song !== undefined && !current + ? [ + act(prefs, "Play Next", (m: Mpd) => + m.command(`moveid ${s.id} ${(s.pos ?? 0) > st.song! ? st.song! + 1 : st.song!}`), { + icon: Icons.next, + shortcut: "<Ctrl>n", + }), + ] + : []), + act(prefs, "Remove from Queue", (m: Mpd) => m.command(`deleteid ${s.id}`), { + icon: Icons.minus, + shortcut: "<Ctrl>Delete", + section: "Queue", + destructive: true, + }), + ...playerActions(st, prefs).slice(1, 3), + ...libraryActions(s, prefs).filter((a) => a.section === "Copy" || a.section === "Album").filter((a) => + !a.title.startsWith("Play Album") + ), + ...queueActions(st, prefs), + ], + }; +} + +export default (ctx: Context) => { + const prefs = prefsOf(ctx); + return list({ + placeholder: "Filter the queue…", + status: () => status, + empty: { title: "MPD", icon: Icons.music }, + // Polls every second for the position; the queue is reloaded only when it changes. + async *items({ signal }) { + let songs: Song[] = []; + let version = -1; + let lastKey = ""; + let lastSong = ""; + while (!signal.aborted) { + let items: Item[]; + try { + items = await run(prefs, async (m) => { + const { status: st, current } = await snapshot(m); + if (st.playlistVersion !== version) { + songs = await queue(m); + version = st.playlistVersion; + } + const art = prefs.covers === false ? undefined : await cover(m, current); + const line = current && st.state !== "stop" ? songLine(current) : ""; + if (line !== lastSong) { + lastSong = line; + updateCommandMetadata({ subtitle: line ? `${st.state === "pause" ? "Paused · " : ""}${line}` : null }) + .catch(() => {}); + } + const icon = st.state === "play" ? "▶" : st.state === "pause" ? "⏸" : "■"; + status = `${icon} ${st.playlistLength} song${st.playlistLength === 1 ? "" : "s"} in queue${ + st.volume !== undefined ? ` · volume ${st.volume} %` : "" + }${st.updating ? " · updating library" : ""}`; + return [nowPlaying(st, current, art, prefs), ...songs.map((s) => queueItem(s, st, prefs))]; + }); + } catch (e) { + status = "MPD unavailable"; + items = [await errorItem(e)]; + } + const key = JSON.stringify( + items.map(( + i, + ) => [i.id, i.title, i.subtitle, i.accessories, i.icon, i.detail, i.actions?.map((a) => a.title)]), + ); + if (key !== lastKey) { + lastKey = key; + yield items; + } + await new Promise((r) => setTimeout(r, 1000)); + } + }, + }); +}; diff --git a/extensions/mpd/commands/next.ts b/extensions/mpd/commands/next.ts new file mode 100644 index 0000000..79fdb61 --- /dev/null +++ b/extensions/mpd/commands/next.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { songLine } from "../lib.ts"; +import { type Prefs, run as mpd, snapshot } from "../music.ts"; + +export default run(async ({ preferences }) => { + const now = await mpd(preferences as Prefs, async (m) => { + await m.command("next"); + return await snapshot(m); + }); + return now.current ? songLine(now.current) : "End of the queue"; +}); diff --git a/extensions/mpd/commands/play-pause.ts b/extensions/mpd/commands/play-pause.ts new file mode 100644 index 0000000..0e2be93 --- /dev/null +++ b/extensions/mpd/commands/play-pause.ts @@ -0,0 +1,9 @@ +import { run } from "@backslash/api"; +import { songLine } from "../lib.ts"; +import { playPause, type Prefs, run as mpd } from "../music.ts"; + +export default run(async ({ preferences }) => { + const before = await mpd(preferences as Prefs, playPause); + const song = before.current ? songLine(before.current) : ""; + return `${before.status.state === "play" ? "Paused" : "Playing"}${song ? ` · ${song}` : ""}`; +}); diff --git a/extensions/mpd/commands/playlists.ts b/extensions/mpd/commands/playlists.ts new file mode 100644 index 0000000..de40842 --- /dev/null +++ b/extensions/mpd/commands/playlists.ts @@ -0,0 +1,62 @@ +import { action, type Context, Icons, type Item, list, meta } from "@backslash/api"; +import { type Playlist, quote } from "../lib.ts"; +import { playlists, playlistSongs, type Prefs, replaceAndPlay, run } from "../music.ts"; +import { act, errorItem, prefsOf, savePlaylistForm, songsList } from "../ui.ts"; + +export function playlistItem(p: Playlist, prefs: Prefs): Item { + const modified = p.modified ? new Date(p.modified) : undefined; + return { + id: `playlist:${p.name}`, + title: p.name, + icon: { source: Icons.music, tint: "purple" }, + section: "Playlists", + detail: { + metadata: [ + meta.text("Playlist", p.name, { icon: Icons.music }), + ...(modified && !Number.isNaN(modified.getTime()) + ? [ + meta.text("Modified", modified.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + ] + : []), + ], + }, + actions: [ + act(prefs, "Play Playlist", (m) => replaceAndPlay(m, { playlist: p.name }), { icon: Icons.play }), + act(prefs, "Add to Queue", (m) => m.command(`load ${quote(p.name)}`), { icon: Icons.plus, shortcut: "<Ctrl>a" }), + act(prefs, "Shuffle Play", (m) => m.commands(["clear", `load ${quote(p.name)}`, "shuffle", "play 0"]), { + icon: Icons.shuffle, + }), + action("Show Songs", () => + songsList(p.name, () => run(prefs, (m) => playlistSongs(m, p.name)), prefs, [ + act(prefs, "Play Playlist", (m) => replaceAndPlay(m, { playlist: p.name }), { icon: Icons.play }), + ]), { icon: Icons.eye, shortcut: "<Ctrl>o" }), + act(prefs, "Delete Playlist", (m) => m.command(`rm ${quote(p.name)}`), { + icon: Icons.trash, + section: "Danger", + destructive: true, + confirm: `Delete the playlist “${p.name}”?`, + }), + ], + }; +} + +export default (ctx: Context) => { + const prefs = prefsOf(ctx); + return list({ + placeholder: "Filter playlists…", + status: (items) => `${items.filter((i) => i.section === "Playlists").length} playlists`, + empty: { title: "No playlist yet", description: "Save the queue as a playlist from Ctrl+K", icon: Icons.music }, + actions: [ + action("Save Queue as Playlist…", () => savePlaylistForm(prefs), { icon: Icons.plus, shortcut: "<Ctrl>n" }), + ], + async items() { + try { + return (await run(prefs, playlists)).map((p) => playlistItem(p, prefs)); + } catch (e) { + return [await errorItem(e)]; + } + }, + }); +}; diff --git a/extensions/mpd/commands/previous.ts b/extensions/mpd/commands/previous.ts new file mode 100644 index 0000000..cb67d55 --- /dev/null +++ b/extensions/mpd/commands/previous.ts @@ -0,0 +1,11 @@ +import { run } from "@backslash/api"; +import { songLine } from "../lib.ts"; +import { type Prefs, run as mpd, snapshot } from "../music.ts"; + +export default run(async ({ preferences }) => { + const now = await mpd(preferences as Prefs, async (m) => { + await m.command("previous"); + return await snapshot(m); + }); + return now.current ? songLine(now.current) : "Stopped"; +}); diff --git a/extensions/mpd/commands/search.ts b/extensions/mpd/commands/search.ts new file mode 100644 index 0000000..2408631 --- /dev/null +++ b/extensions/mpd/commands/search.ts @@ -0,0 +1,121 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { quote, type Song } from "../lib.ts"; +import { playlists, run, search, stats } from "../music.ts"; +import { act, errorItem, prefsOf, songItem } from "../ui.ts"; +import { playlistItem } from "./playlists.ts"; + +let status = ""; + +/** Albums and artists found among the songs, most songs first. */ +function groups(songs: Song[]) { + const albums = new Map<string, { album: string; artist?: string; songs: Song[] }>(); + const artists = new Map<string, number>(); + for (const s of songs) { + if (s.album) { + const key = `${s.albumArtist ?? s.artist ?? ""}\n${s.album}`; + const g = albums.get(key) ?? { album: s.album, artist: s.albumArtist ?? s.artist, songs: [] }; + g.songs.push(s); + albums.set(key, g); + } + if (s.artist) artists.set(s.artist, (artists.get(s.artist) ?? 0) + 1); + } + return { + albums: [...albums.values()].sort((a, b) => b.songs.length - a.songs.length).slice(0, 8), + artists: [...artists].sort((a, b) => b[1] - a[1]).slice(0, 5), + }; +} + +export default (ctx: Context) => { + const prefs = prefsOf(ctx); + return list({ + placeholder: "Search songs, artists, albums…", + status: () => status, + empty: { + title: "Nothing found", + description: "Search matches titles, artists, albums and file names", + icon: Icons.music, + }, + async search(query) { + const text = query.trim(); + try { + return await run(prefs, async (m) => { + if (!text) { + const [st, pls] = await Promise.all([stats(m), playlists(m)]); + status = `${st.songs.toLocaleString("en-US")} songs · ${st.albums.toLocaleString("en-US")} albums · ${ + st.artists.toLocaleString("en-US") + } artists`; + return [ + { + id: "hint", + title: "Type to search your library", + subtitle: status, + icon: { source: Icons.search, tint: "gray" }, + section: "Library", + actions: [act(prefs, "Update Library", (mm) => mm.command("update"), { icon: Icons.refresh })], + }, + ...pls.map((p) => playlistItem(p, prefs)), + ]; + } + const songs = await search(m, text); + status = `${songs.length}${songs.length === 150 ? "+" : ""} songs`; + const { albums, artists } = groups(songs); + const albumItems = albums.filter((g) => + g.songs.length > 1 || g.album.toLowerCase().includes(text.toLowerCase()) + ).map((g): Item => ({ + id: `album:${g.artist}:${g.album}`, + title: g.album, + subtitle: g.artist, + icon: { source: Icons.layers, tint: "purple" }, + section: "Albums", + accessories: [{ text: `${g.songs.length} found` }], + actions: [ + act( + prefs, + "Play Album", + (mm) => + mm.commands([ + "clear", + `findadd album ${quote(g.album)}${g.artist ? ` albumartist ${quote(g.artist)}` : ""}`, + "play 0", + ]), + { icon: Icons.play }, + ), + act( + prefs, + "Add Album to Queue", + (mm) => + mm.command(`findadd album ${quote(g.album)}${g.artist ? ` albumartist ${quote(g.artist)}` : ""}`), + { icon: Icons.plus }, + ), + ], + })); + const artistItems = artists.filter(([a]) => a.toLowerCase().includes(text.toLowerCase())).map(( + [a, n], + ): Item => ({ + id: `artist:${a}`, + title: a, + icon: { source: Icons.person, tint: "blue" }, + section: "Artists", + accessories: [{ text: `${n} found` }], + actions: [ + act(prefs, `Play ${a}`, (mm) => mm.commands(["clear", `findadd artist ${quote(a)}`, "play 0"]), { + icon: Icons.play, + }), + act(prefs, "Add to Queue", (mm) => mm.command(`findadd artist ${quote(a)}`), { icon: Icons.plus }), + act( + prefs, + "Shuffle Artist", + (mm) => mm.commands(["clear", `findadd artist ${quote(a)}`, "shuffle", "play 0"]), + { icon: Icons.shuffle }, + ), + ], + })); + return [...songs.map((s) => songItem(s, prefs, "Songs")), ...albumItems, ...artistItems]; + }); + } catch (e) { + status = "MPD unavailable"; + return [await errorItem(e)]; + } + }, + }); +}; diff --git a/extensions/mpd/lib.ts b/extensions/mpd/lib.ts new file mode 100644 index 0000000..652617a --- /dev/null +++ b/extensions/mpd/lib.ts @@ -0,0 +1,261 @@ +// The MPD protocol's text answers, songs, and formatting. Pure, tested in lib_test.ts. + +/** "key: value" lines; repeated keys are kept in order. */ +export function parsePairs(text: string): [string, string][] { + return text.split("\n").flatMap((line): [string, string][] => { + const i = line.indexOf(": "); + return i > 0 ? [[line.slice(0, i), line.slice(i + 2)]] : []; + }); +} + +/** Groups pairs into objects, a new one starting at each `startKey` ("file", "playlist"…). */ +export function parseObjects(text: string, startKeys: string[]): Record<string, string>[] { + const out: Record<string, string>[] = []; + let cur: Record<string, string> | undefined; + for (const [k, v] of parsePairs(text)) { + if (startKeys.includes(k)) { + cur = { [k]: v }; + out.push(cur); + } else if (cur && !(k in cur)) cur[k] = v; + } + return out; +} + +export const parseRecord = (text: string): Record<string, string> => Object.fromEntries(parsePairs(text).reverse()); + +/** Quotes an argument the way MPD wants: in double quotes, with \ and " escaped. */ +export const quote = (arg: string | number) => `"${String(arg).replace(/[\\"]/g, "\\$&")}"`; + +// --------------------------------------------------------------------------- +// Songs and status + +export interface Song { + file: string; + title: string; + artist?: string; + album?: string; + albumArtist?: string; + date?: string; + track?: string; + genre?: string; + /** Seconds. */ + duration?: number; + /** In the queue. */ + pos?: number; + id?: number; + /** A stream (http://…) rather than a file of the library. */ + stream: boolean; + /** Radio streams send the current song as Title, their name as Name. */ + name?: string; +} + +const basename = (file: string) => decodeURIComponent(file.split("/").pop() ?? file).replace(/\.[a-z0-9]{2,5}$/i, ""); + +export function toSong(o: Record<string, string>): Song { + const stream = /^[a-z]+:\/\//i.test(o.file ?? ""); + const duration = Number(o.duration ?? o.Time); + return { + file: o.file ?? "", + title: o.Title || o.Name || basename(o.file ?? "Unknown"), + artist: o.Artist || o.AlbumArtist || undefined, + album: o.Album || undefined, + albumArtist: o.AlbumArtist || undefined, + date: o.Date?.slice(0, 4) || undefined, + track: o.Track?.split("/")[0] || undefined, + genre: o.Genre || undefined, + duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, + pos: o.Pos !== undefined ? Number(o.Pos) : undefined, + id: o.Id !== undefined ? Number(o.Id) : undefined, + stream, + name: o.Name || undefined, + }; +} + +export const parseSongs = (text: string): Song[] => parseObjects(text, ["file"]).map(toSong); + +export type State = "play" | "pause" | "stop"; + +export interface Status { + state: State; + /** 0–100, or undefined without a mixer. */ + volume?: number; + repeat: boolean; + random: boolean; + single: "0" | "1" | "oneshot"; + consume: "0" | "1" | "oneshot"; + playlistLength: number; + /** Changes whenever the queue changes. */ + playlistVersion: number; + song?: number; + songId?: number; + nextSong?: number; + elapsed?: number; + duration?: number; + bitrate?: number; + audio?: string; + error?: string; + updating: boolean; +} + +export function parseStatus(text: string): Status { + const r = parseRecord(text); + const num = (k: string) => (r[k] !== undefined && r[k] !== "" ? Number(r[k]) : undefined); + const volume = num("volume"); + return { + state: (r.state as State) ?? "stop", + volume: volume === undefined || volume < 0 ? undefined : volume, + repeat: r.repeat === "1", + random: r.random === "1", + single: (r.single as Status["single"]) ?? "0", + consume: (r.consume as Status["consume"]) ?? "0", + playlistLength: num("playlistlength") ?? 0, + playlistVersion: num("playlist") ?? 0, + song: num("song"), + songId: num("songid"), + nextSong: num("nextsong"), + elapsed: num("elapsed") ?? (r.time ? Number(r.time.split(":")[0]) : undefined), + duration: num("duration") ?? (r.time ? Number(r.time.split(":")[1]) : undefined), + bitrate: num("bitrate"), + audio: r.audio, + error: r.error, + updating: r.updating_db !== undefined, + }; +} + +export interface Playlist { + name: string; + modified?: string; +} + +export const parsePlaylists = (text: string): Playlist[] => + parseObjects(text, ["playlist"]).map((o) => ({ name: o.playlist, modified: o["Last-Modified"] })) + .sort((a, b) => a.name.localeCompare(b.name)); + +export interface Stats { + artists: number; + albums: number; + songs: number; + /** Seconds of music in the library. */ + dbPlaytime: number; +} + +export function parseStats(text: string): Stats { + const r = parseRecord(text); + return { + artists: Number(r.artists ?? 0), + albums: Number(r.albums ?? 0), + songs: Number(r.songs ?? 0), + dbPlaytime: Number(r.db_playtime ?? 0), + }; +} + +/** "ACK [50@0] {albumart} No file exists" → its parts. */ +export function parseAck(line: string): { code: number; command: string; message: string } | undefined { + const m = line.match(/^ACK \[(\d+)@\d+\] \{([^}]*)\} ?(.*)$/); + return m ? { code: Number(m[1]), command: m[2], message: m[3] } : undefined; +} + +export const ACK_NO_EXIST = 50; +export const ACK_PASSWORD = 3; +export const ACK_PERMISSION = 4; + +// --------------------------------------------------------------------------- +// Formatting + +export function clock(seconds: number | undefined): string { + if (seconds === undefined || !Number.isFinite(seconds)) return "–:––"; + const s = Math.floor(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const pad = (n: number) => String(n).padStart(2, "0"); + return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`; +} + +export function totalTime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + if (seconds >= 86400) return `${Math.floor(seconds / 86400)} d ${Math.floor((seconds % 86400) / 3600)} h`; + return h ? `${h} h ${m} min` : `${m} min`; +} + +/** "Daft Punk — Get Lucky" */ +export const songLine = (s: Song) => [s.artist, s.title].filter(Boolean).join(" — "); + +/** "Random Access Memories (2013)" */ +export const albumLine = (s: Song) => (s.album ? `${s.album}${s.date ? ` (${s.date})` : ""}` : undefined); + +/** "44100:24:2" → "44.1 kHz · 24 bit · stereo". */ +export function audioFormat(audio: string | undefined): string | undefined { + if (!audio) return undefined; + const [rate, bits, channels] = audio.split(":"); + const r = Number(rate); + const ch = channels === "1" ? "mono" : channels === "2" ? "stereo" : `${channels} channels`; + return [ + r ? `${(r / 1000).toFixed(r % 1000 ? 1 : 0)} kHz` : undefined, + bits && bits !== "f" ? `${bits} bit` : bits === "f" ? "float" : undefined, + channels ? ch : undefined, + ] + .filter(Boolean).join(" · "); +} + +/** The single/consume toggles cycle off → on → once. */ +export const nextMode = ( + m: "0" | "1" | "oneshot", +): "0" | "1" | "oneshot" => (m === "0" ? "1" : m === "1" ? "oneshot" : "0"); +export const modeName = (m: "0" | "1" | "oneshot") => (m === "0" ? "Off" : m === "1" ? "On" : "Once"); + +/** A stable file name for a song's cover: FNV-1a of its folder (covers are per album folder). */ +export function coverKey(file: string): string { + const folder = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : file; + let h = 0x811c9dc5; + for (const b of new TextEncoder().encode(folder)) h = Math.imul(h ^ b, 0x01000193) >>> 0; + return h.toString(16).padStart(8, "0"); +} + +/** The picture type from its first bytes. */ +export function imageExtension(bytes: Uint8Array): string { + if (bytes[0] === 0x89 && bytes[1] === 0x50) return "png"; + if (bytes[0] === 0xff && bytes[1] === 0xd8) return "jpg"; + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[8] === 0x57) return "webp"; + if (bytes[0] === 0x47 && bytes[1] === 0x49) return "gif"; + return "jpg"; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record<Family, string> = { + arch: "sudo pacman -S mpd", + debian: "sudo apt install mpd", + fedora: "sudo dnf install mpd", + suse: "sudo zypper install mpd", +}; + +const FAMILY_NAMES: Record<Family, string> = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +export function installMarkdown(family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + return [ + "Backslash talks to the **Music Player Daemon** on `localhost:6600`. Install it, point `music_directory` to your music in `~/.config/mpd/mpd.conf`, then start it:", + ...families.map((f) => `**${FAMILY_NAMES[f]}**\n\n\`${MANAGERS[f]}\``), + "`systemctl --user enable --now mpd`", + 'If MPD listens elsewhere (a socket only, another port), add `bind_to_address "localhost"` and `port "6600"` to its configuration.', + ].join("\n\n"); +} diff --git a/extensions/mpd/lib_test.ts b/extensions/mpd/lib_test.ts new file mode 100644 index 0000000..e313d73 --- /dev/null +++ b/extensions/mpd/lib_test.ts @@ -0,0 +1,154 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + albumLine, + audioFormat, + clock, + coverKey, + imageExtension, + installMarkdown, + modeName, + nextMode, + osFamily, + parseAck, + parsePairs, + parsePlaylists, + parseSongs, + parseStats, + parseStatus, + quote, + songLine, + totalTime, +} from "./lib.ts"; + +// Real MPD 0.23 answers. +const STATUS = `volume: 70 +repeat: 1 +random: 0 +single: oneshot +consume: 0 +partition: default +playlist: 42 +playlistlength: 3 +mixrampdb: 0.000000 +state: play +song: 1 +songid: 2 +time: 61:274 +elapsed: 61.204 +bitrate: 945 +duration: 274.000 +audio: 44100:16:2 +nextsong: 2 +nextsongid: 3`; + +const QUEUE = `file: Daft Punk/Random Access Memories/08 Get Lucky.flac +Last-Modified: 2023-01-01T10:00:00Z +Format: 44100:16:2 +Artist: Daft Punk +Artist: Pharrell Williams +AlbumArtist: Daft Punk +Title: Get Lucky +Album: Random Access Memories +Track: 8/13 +Date: 2013-05-17 +Genre: Disco +Time: 370 +duration: 369.629 +Pos: 0 +Id: 1 +file: Air/Moon Safari/01 La femme d'argent.mp3 +Title: La femme d'argent +Artist: Air +Album: Moon Safari +Date: 1998 +duration: 429.500 +Pos: 1 +Id: 2 +file: http://ice.example.org/fip.mp3 +Name: FIP +Pos: 2 +Id: 3 +file: Unsorted/My%20Song.ogg +Pos: 3 +Id: 4`; + +Deno.test("status", () => { + const s = parseStatus(STATUS); + assertEquals([s.state, s.volume, s.repeat, s.random, s.single, s.consume], ["play", 70, true, false, "oneshot", "0"]); + assertEquals([s.playlistLength, s.playlistVersion, s.song, s.songId, s.nextSong], [3, 42, 1, 2, 2]); + assertEquals([s.elapsed, s.duration, s.bitrate, s.updating], [61.204, 274, 945, false]); + const old = parseStatus("volume: -1\nstate: pause\ntime: 12:300\nupdating_db: 3\n"); + assertEquals([old.volume, old.state, old.elapsed, old.duration, old.updating], [undefined, "pause", 12, 300, true]); + assertEquals(parseStatus("").state, "stop"); +}); + +Deno.test("songs", () => { + const songs = parseSongs(QUEUE); + assertEquals(songs.length, 4); + const [lucky, air, fip, bare] = songs; + assertEquals([lucky.title, lucky.artist, lucky.album, lucky.track, lucky.date, lucky.duration, lucky.id], [ + "Get Lucky", + "Daft Punk", + "Random Access Memories", + "8", + "2013", + 369.629, + 1, + ]); + assertEquals(songLine(lucky), "Daft Punk — Get Lucky"); + assertEquals(albumLine(lucky), "Random Access Memories (2013)"); + assertEquals([air.pos, air.artist], [1, "Air"]); + assertEquals([fip.title, fip.stream, fip.duration], ["FIP", true, undefined]); + assertEquals([bare.title, bare.artist], ["My Song", undefined]); + assertEquals(parsePairs("a: b: c\nnot a pair\n"), [["a", "b: c"]]); +}); + +Deno.test("playlists, stats and errors", () => { + const pls = parsePlaylists( + "playlist: Workout\nLast-Modified: 2026-10-01T10:00:00Z\nplaylist: Chill\nLast-Modified: 2026-09-01T10:00:00Z\n", + ); + assertEquals(pls.map((p) => p.name), ["Chill", "Workout"]); + assertEquals(pls[1].modified, "2026-10-01T10:00:00Z"); + assertEquals(parseStats("artists: 120\nalbums: 300\nsongs: 4211\nuptime: 5\ndb_playtime: 1000000\n"), { + artists: 120, + albums: 300, + songs: 4211, + dbPlaytime: 1000000, + }); + assertEquals(parseAck("ACK [50@0] {albumart} No file exists"), { + code: 50, + command: "albumart", + message: "No file exists", + }); + assertEquals(parseAck("ACK [3@0] {} incorrect password"), { code: 3, command: "", message: "incorrect password" }); + assertEquals(parseAck("OK"), undefined); +}); + +Deno.test("quoting and formatting", () => { + assertEquals(quote('AC/DC "Live" \\ 1991'), '"AC/DC \\"Live\\" \\\\ 1991"'); + assertEquals(quote(3), '"3"'); + assertEquals(clock(61.9), "1:01"); + assertEquals(clock(3725), "1:02:05"); + assertEquals(clock(undefined), "–:––"); + assertEquals(totalTime(5400), "1 h 30 min"); + assertEquals(totalTime(200000), "2 d 7 h"); + assertEquals(audioFormat("44100:16:2"), "44.1 kHz · 16 bit · stereo"); + assertEquals(audioFormat("96000:24:2"), "96 kHz · 24 bit · stereo"); + assertEquals(audioFormat("48000:f:1"), "48 kHz · float · mono"); + assertEquals([nextMode("0"), nextMode("1"), nextMode("oneshot")], ["1", "oneshot", "0"]); + assertEquals(modeName("oneshot"), "Once"); +}); + +Deno.test("covers", () => { + assertEquals(coverKey("Air/Moon Safari/01.mp3"), coverKey("Air/Moon Safari/02.mp3"), "one cover per folder"); + assertEquals(coverKey("Air/Moon Safari/01.mp3") === coverKey("Air/Talkie Walkie/01.mp3"), false); + assertEquals(imageExtension(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), "jpg"); + assertEquals(imageExtension(new Uint8Array([0x89, 0x50, 0x4e, 0x47])), "png"); + assertEquals(imageExtension(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45])), "webp"); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily('ID=linuxmint\nID_LIKE="ubuntu debian"\n'), "debian"); + assertStringIncludes(installMarkdown("arch"), "sudo pacman -S mpd"); + assertStringIncludes(installMarkdown(undefined), "systemctl --user enable --now mpd"); +}); diff --git a/extensions/mpd/music.ts b/extensions/mpd/music.ts new file mode 100644 index 0000000..6a80d3a --- /dev/null +++ b/extensions/mpd/music.ts @@ -0,0 +1,137 @@ +// What the commands do with MPD, and album covers. + +import { exec } from "@backslash/api"; +import { Mpd, withMpd } from "./client.ts"; +import { + coverKey, + type Family, + imageExtension, + osFamily, + parsePlaylists, + parseSongs, + parseStats, + parseStatus, + type Playlist, + quote, + type Song, + type Stats, + type Status, +} 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 COVERS = `${HOME}/.cache/backslash/mpd`; + +export interface Prefs { + password?: string; + covers?: boolean; +} + +export const run = <T>(prefs: Prefs, fn: (mpd: Mpd) => Promise<T>) => withMpd(prefs.password || undefined, fn); + +export async function family(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +export interface Snapshot { + status: Status; + current?: Song; +} + +export async function snapshot(mpd: Mpd): Promise<Snapshot> { + const status = parseStatus(await mpd.command("status")); + const current = status.songId !== undefined ? parseSongs(await mpd.command("currentsong"))[0] : undefined; + return { status, current }; +} + +export const queue = async (mpd: Mpd): Promise<Song[]> => parseSongs(await mpd.command("playlistinfo")); +export const playlists = async (mpd: Mpd): Promise<Playlist[]> => parsePlaylists(await mpd.command("listplaylists")); +export const playlistSongs = async (mpd: Mpd, name: string): Promise<Song[]> => + parseSongs(await mpd.command(`listplaylistinfo ${quote(name)}`)); +export const stats = async (mpd: Mpd): Promise<Stats> => parseStats(await mpd.command("stats")); + +/** Songs whose tags or file name contain the text (case-insensitive), at most `limit`. */ +export async function search(mpd: Mpd, text: string, limit = 150): Promise<Song[]> { + return parseSongs(await mpd.command(`search any ${quote(text)} window 0:${limit}`)); +} + +/** Adds after the song playing (or at the end when nothing plays). */ +export async function playNext(mpd: Mpd, uris: string[]): Promise<void> { + const { status } = await snapshot(mpd); + const at = status.song !== undefined ? status.song + 1 : status.playlistLength; + await mpd.commands(uris.map((u, i) => `addid ${quote(u)} ${at + i}`)); +} + +/** Adds and plays the first one. */ +export async function playNow(mpd: Mpd, uris: string[]): Promise<void> { + const { status } = await snapshot(mpd); + const at = status.song !== undefined ? status.song + 1 : status.playlistLength; + const id = Number((await mpd.command(`addid ${quote(uris[0])} ${at}`)).match(/Id: (\d+)/)?.[1]); + if (uris.length > 1) await mpd.commands(uris.slice(1).map((u, i) => `addid ${quote(u)} ${at + 1 + i}`)); + await mpd.command(`playid ${id}`); +} + +export const add = (mpd: Mpd, uris: string[]) => mpd.commands(uris.map((u) => `add ${quote(u)}`)); + +/** Clears the queue, loads the songs or a playlist, plays from the start. */ +export async function replaceAndPlay(mpd: Mpd, what: { uris?: string[]; playlist?: string }): Promise<void> { + await mpd.commands([ + "clear", + ...(what.playlist ? [`load ${quote(what.playlist)}`] : (what.uris ?? []).map((u) => `add ${quote(u)}`)), + "play 0", + ]); +} + +export async function playPause(mpd: Mpd): Promise<Snapshot> { + const s = await snapshot(mpd); + if (s.status.state === "play") await mpd.command("pause 1"); + else await mpd.command("play"); + return s; +} + +export async function setVolume(mpd: Mpd, volume: number): Promise<void> { + await mpd.command(`setvol ${Math.max(0, Math.min(100, Math.round(volume)))}`); +} + +// --------------------------------------------------------------------------- +// Covers + +const missing = new Set<string>(); + +/** The cover of a song's album: MPD's albumart (cover file in the folder), else readpicture (embedded). */ +export async function cover(mpd: Mpd, song: Song | undefined): Promise<string | undefined> { + if (!song || song.stream || !song.file) return undefined; + const key = coverKey(song.file); + if (missing.has(key)) return undefined; + for (const ext of ["jpg", "png", "webp", "gif"]) { + const path = `${COVERS}/${key}.${ext}`; + if ((await Deno.stat(path).catch(() => undefined))?.size) return path; + } + try { + const bytes = (await mpd.picture("albumart", song.file)) ?? (await mpd.picture("readpicture", song.file)); + if (!bytes?.length) { + missing.add(key); + return undefined; + } + await Deno.mkdir(COVERS, { recursive: true }); + const path = `${COVERS}/${key}.${imageExtension(bytes)}`; + await Deno.writeFile(path, bytes); + return path; + } catch { + missing.add(key); + return undefined; + } +} + +/** Starts MPD's user service (systemd), for the "not running" state. */ +export async function startService(): Promise<void> { + await exec("systemctl", ["--user", "start", "mpd.service"]); + await new Promise((r) => setTimeout(r, 800)); +} diff --git a/extensions/mpd/ui.ts b/extensions/mpd/ui.ts new file mode 100644 index 0000000..a6b2dff --- /dev/null +++ b/extensions/mpd/ui.ts @@ -0,0 +1,281 @@ +// Items and actions shared by the commands. + +import { + type Action, + action, + type Context, + copy, + field, + form, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + pop, + refresh, + submenu, + toast, +} from "@backslash/api"; +import { type Mpd, MpdError, Unreachable } from "./client.ts"; +import { + albumLine, + clock, + installMarkdown, + modeName, + nextMode, + quote, + type Song, + songLine, + type Status, +} from "./lib.ts"; +import { add, family, playNext, playNow, type Prefs, run, setVolume, startService } from "./music.ts"; + +export const prefsOf = (ctx: Context) => ctx.preferences as Prefs; + +/** An action that talks to MPD, then reloads the view. */ +export const act = ( + prefs: Prefs, + title: string, + fn: (mpd: Mpd) => Promise<unknown>, + options: Parameters<typeof action>[2] = {}, +) => + action(title, async () => { + try { + await run(prefs, fn); + } catch (e) { + if (e instanceof MpdError) { + await toast(e.message, { style: "failure" }); + return; + } + throw e; + } + return refresh(); + }, options); + +/** The empty state when MPD can't be reached, or refuses the password. */ +export async function errorItem(e: unknown): Promise<Item> { + if (e instanceof MpdError && /password|permission/i.test(e.message)) { + return { + id: "password", + title: "MPD wants a password", + subtitle: e.message, + icon: { source: Icons.lock, tint: "orange" }, + detail: { markdown: "Set the **Password** preference of this extension to MPD's `password` setting." }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; + } + if (e instanceof MpdError) { + return { + id: "error", + title: "MPD answered with an error", + subtitle: e.message, + icon: { source: Icons.alert, tint: "red" }, + detail: { markdown: `\`${e.message}\`` }, + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" })], + }; + } + if (!(e instanceof Unreachable)) throw e; + return { + id: "unreachable", + title: e.message, + subtitle: "Start it, or install the Music Player Daemon", + icon: { source: Icons.alert, tint: "orange" }, + detail: { markdown: installMarkdown(await family()) }, + actions: [ + action("Start MPD", async () => { + try { + await startService(); + } catch (err) { + await toast("Couldn't start mpd.service", { style: "failure", message: (err as Error).message }); + return; + } + return refresh(); + }, { icon: Icons.play }), + action("Check Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + ], + }; +} + +export function songMetadata(s: Song, extra: Metadata[] = []): Metadata[] { + return [ + ...(s.artist ? [meta.text("Artist", s.artist, { icon: Icons.person })] : []), + ...(albumLine(s) ? [meta.text("Album", albumLine(s)!, { icon: Icons.music })] : []), + ...(s.albumArtist && s.albumArtist !== s.artist ? [meta.text("Album artist", s.albumArtist)] : []), + ...(s.track ? [meta.text("Track", s.track, { icon: Icons.hash })] : []), + ...(s.genre ? [meta.tags("Genre", s.genre.split(/[;,]\s*/))] : []), + ...(s.duration ? [meta.text("Duration", clock(s.duration), { icon: Icons.clock })] : []), + ...(s.stream ? [meta.text("Stream", s.name ?? s.file, { icon: Icons.radio })] : []), + ...extra, + meta.separator(), + meta.text("File", s.file, { icon: Icons.file }), + ]; +} + +/** Play now, play next, add to the queue, the whole album. */ +export function libraryActions(s: Song, prefs: Prefs): Action[] { + return [ + act(prefs, "Play Now", (m) => playNow(m, [s.file]), { icon: Icons.play }), + act(prefs, "Play Next", (m) => playNext(m, [s.file]), { icon: Icons.next, shortcut: "<Ctrl>n" }), + act(prefs, "Add to Queue", (m) => add(m, [s.file]), { icon: Icons.plus, shortcut: "<Ctrl>a" }), + ...(s.album + ? [ + act(prefs, "Play Album", async (m) => { + await m.commands([ + "clear", + `findadd album ${quote(s.album!)}${s.albumArtist ? ` albumartist ${quote(s.albumArtist)}` : ""}`, + "play 0", + ]); + }, { icon: Icons.music, section: "Album" }), + act(prefs, "Add Album to Queue", (m) => + m.command(`findadd album ${quote(s.album!)}${s.albumArtist ? ` albumartist ${quote(s.albumArtist)}` : ""}`), { + icon: Icons.plus, + section: "Album", + }), + ] + : []), + ...(s.artist + ? [act(prefs, `Add Everything by ${s.artist}`, (m) => m.command(`findadd artist ${quote(s.artist!)}`), { + icon: Icons.person, + section: "Album", + })] + : []), + copy(songLine(s), "Copy Song Name", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(s.file, "Copy File Path", { section: "Copy" }), + ]; +} + +export function songItem(s: Song, prefs: Prefs, section?: string): Item { + return { + id: `song:${s.file}`, + title: s.title, + subtitle: [s.artist, s.album].filter(Boolean).join(" · "), + icon: { source: s.stream ? Icons.radio : Icons.music, tint: "gray" }, + section, + keywords: [s.artist ?? "", s.album ?? "", s.file], + accessories: s.duration ? [{ text: clock(s.duration) }] : [], + detail: { metadata: songMetadata(s) }, + actions: libraryActions(s, prefs), + }; +} + +// --------------------------------------------------------------------------- +// Player controls + +export function modeTags(st: Status): Metadata { + return meta.tags("Modes", [ + { text: "Random", color: st.random ? "green" : "gray" }, + { text: "Repeat", color: st.repeat ? "green" : "gray" }, + { text: `Single${st.single === "oneshot" ? " once" : ""}`, color: st.single !== "0" ? "green" : "gray" }, + { text: `Consume${st.consume === "oneshot" ? " once" : ""}`, color: st.consume !== "0" ? "green" : "gray" }, + ]); +} + +export function playerActions(st: Status, prefs: Prefs): Action[] { + const playing = st.state === "play"; + const vol = st.volume; + return [ + act(prefs, playing ? "Pause" : "Play", (m) => m.command(playing ? "pause 1" : "play"), { + icon: playing ? Icons.pause : Icons.play, + }), + act(prefs, "Next Track", (m) => m.command("next"), { icon: Icons.next, shortcut: "<Ctrl>n" }), + act(prefs, "Previous Track", (m) => m.command("previous"), { icon: Icons.previous, shortcut: "<Ctrl>p" }), + act(prefs, "Stop", (m) => m.command("stop"), { icon: Icons.stop, section: "Playback" }), + submenu("Seek", [ + act(prefs, "Forward 10 Seconds", (m) => m.command("seekcur +10"), { icon: Icons.arrowRight }), + act(prefs, "Forward 1 Minute", (m) => m.command("seekcur +60"), { icon: Icons.arrowRight }), + act(prefs, "Back 10 Seconds", (m) => m.command("seekcur -10"), { icon: Icons.previous }), + act(prefs, "Restart Song", (m) => m.command("seekcur 0"), { icon: Icons.refresh }), + ], { icon: Icons.clock, section: "Playback" }), + ...(vol !== undefined + ? [ + act(prefs, `Volume Up (${vol} %)`, (m) => setVolume(m, vol + 5), { + icon: Icons.speaker, + shortcut: "<Ctrl>Up", + section: "Volume", + }), + act(prefs, "Volume Down", (m) => setVolume(m, vol - 5), { + icon: Icons.speaker, + shortcut: "<Ctrl>Down", + section: "Volume", + }), + submenu( + "Set Volume", + [100, 80, 60, 40, 20, 0].map((v) => + act(prefs, v === 0 ? "Mute" : `${v} %`, (m) => setVolume(m, v), { + icon: v ? Icons.speaker : Icons.speakerOff, + }) + ), + { icon: Icons.speaker, section: "Volume" }, + ), + ] + : []), + submenu("Modes", [ + act(prefs, `Random: ${st.random ? "On" : "Off"}`, (m) => m.command(`random ${st.random ? 0 : 1}`), { + icon: Icons.shuffle, + }), + act(prefs, `Repeat: ${st.repeat ? "On" : "Off"}`, (m) => m.command(`repeat ${st.repeat ? 0 : 1}`), { + icon: Icons.repeat, + }), + act(prefs, `Single: ${modeName(st.single)}`, (m) => m.command(`single ${nextMode(st.single)}`), { + icon: Icons.repeat, + }), + act(prefs, `Consume: ${modeName(st.consume)}`, (m) => m.command(`consume ${nextMode(st.consume)}`), { + icon: Icons.trash, + }), + ], { icon: Icons.settings, shortcut: "<Ctrl>m", section: "Playback" }), + act(prefs, st.random ? "Turn Random Off" : "Turn Random On", (m) => m.command(`random ${st.random ? 0 : 1}`), { + icon: Icons.shuffle, + shortcut: "<Ctrl>s", + section: "Playback", + }), + ]; +} + +export function queueActions(st: Status, prefs: Prefs): Action[] { + if (!st.playlistLength) return []; + return [ + act(prefs, "Shuffle Queue", (m) => m.command("shuffle"), { icon: Icons.shuffle, section: "Queue" }), + action("Save Queue as Playlist…", () => savePlaylistForm(prefs), { icon: Icons.plus, section: "Queue" }), + act(prefs, "Clear Queue", (m) => m.command("clear"), { + icon: Icons.trash, + section: "Danger", + destructive: true, + confirm: `Remove the ${st.playlistLength} songs from the queue?`, + }), + ]; +} + +export function savePlaylistForm(prefs: Prefs) { + return form({ + title: "Save Queue as Playlist", + submitTitle: "Save", + fields: [field.text("name", "Playlist name", { required: true })], + async submit(values) { + const name = String(values.name).trim(); + try { + await run(prefs, (m) => m.command(`save ${quote(name)}`)); + } catch (e) { + if (e instanceof MpdError) { + await toast(e.message, { style: "failure" }); + return; + } + throw e; + } + await toast(`Saved “${name}”`, { style: "success" }); + return pop(); + }, + }); +} + +/** A list of songs (a playlist's), each playable from there. */ +export function songsList(title: string, load: () => Promise<Song[]>, prefs: Prefs, extra: Action[] = []): ListSpec { + return list({ + title, + placeholder: "Filter songs…", + status: (items) => `${items.length} songs`, + actions: extra, + items: async () => (await load()).map((s) => songItem(s, prefs)), + }); +} diff --git a/extensions/nerd-fonts/backslash.toml b/extensions/nerd-fonts/backslash.toml new file mode 100644 index 0000000..8916a20 --- /dev/null +++ b/extensions/nerd-fonts/backslash.toml @@ -0,0 +1,19 @@ +[extension] +id = "nerd-fonts" +title = "Nerd Font Icons" +description = "Search every Nerd Font glyph by name or code point and copy the glyph, its class, code point or HTML entity" +version = "1.0.0" +author = "Backslash" +icon = "bs-font" +license = "MIT" + +# The official glyph list (glyphnames.json), downloaded once and cached. +[permissions] +net = ["raw.githubusercontent.com"] + +[[commands]] +name = "search" +title = "Search Nerd Font Icons" +description = "11,000 glyphs of Material, Font Awesome, Devicons, Codicons, Octicons, Powerline…" +icon = "bs-font" +keywords = ["nerd font", "nerdfont", "glyph", "icon", "symbol", "powerline", "devicons", "font awesome", "icône", "glyphe", "police"] diff --git a/extensions/nerd-fonts/commands/search.ts b/extensions/nerd-fonts/commands/search.ts new file mode 100644 index 0000000..2b09d7f --- /dev/null +++ b/extensions/nerd-fonts/commands/search.ts @@ -0,0 +1,154 @@ +import { + type Action, + action, + clipboard, + close, + copy, + Icons, + type Item, + list, + meta, + open, + refresh, + storage, + toast, +} from "@backslash/api"; +import { + charOf, + className, + codePoint, + countBySet, + cssContent, + type Glyph, + htmlEntity, + jsEscape, + prefixOf, + remember, + searchGlyphs, + SET_NAMES, + SETS, + shortTitle, +} from "../lib.ts"; +import { download, glyphs, POPULAR, recent, reset } from "../glyphs.ts"; + +const CHEAT_SHEET = "https://www.nerdfonts.com/cheat-sheet"; +const DOWNLOADS = "https://www.nerdfonts.com/font-downloads"; + +async function used(name: string) { + await storage.set("recent", remember(await recent(), name)).catch(() => {}); +} + +function glyphItem([name, code]: Glyph, section: string): Item { + const char = charOf(code); + const cls = className(name); + const actions: Action[] = [ + action("Copy Glyph", async () => { + await used(name); + await clipboard.write(char); + return close(`Copied ${cls}`); + }, { icon: Icons.copy }), + action("Paste Glyph", async () => { + await used(name); + await clipboard.paste(char); + }, { icon: Icons.paste }), + copy(cls, `Copy Class (${cls})`, { icon: Icons.code, shortcut: "<Ctrl>period", section: "Copy" }), + copy(codePoint(code), `Copy Code Point (${codePoint(code)})`, { + icon: Icons.hash, + shortcut: "<Ctrl><Shift>c", + section: "Copy", + }), + copy(jsEscape(code), `Copy Escape (${jsEscape(code)})`, { icon: Icons.braces, section: "Copy" }), + copy(htmlEntity(code), `Copy HTML Entity (${htmlEntity(code)})`, { icon: Icons.globe, section: "Copy" }), + copy(cssContent(code), `Copy CSS Content (${cssContent(code)})`, { icon: Icons.palette, section: "Copy" }), + copy(`<i class="nf ${cls}"></i>`, "Copy HTML Tag", { icon: Icons.code, section: "Copy" }), + ]; + return { + id: `${section}:${name}`, + title: shortTitle(name), + subtitle: cls, + icon: char, + section, + keywords: [cls, code], + detail: { + markdown: `# ${char}`, + metadata: [ + meta.text("Class", cls, { icon: Icons.code }), + meta.text("Code point", codePoint(code), { icon: Icons.hash }), + meta.text("HTML", htmlEntity(code)), + meta.tags("Set", [SET_NAMES[prefixOf(name)] ?? prefixOf(name)]), + ], + }, + actions, + }; +} + +const updateAction = action("Update Glyph List", async () => { + await toast("Downloading glyphnames.json…"); + const data = await download(); + reset(); + await toast(`${data.glyphs.length.toLocaleString("en-US")} glyphs`, { + style: "success", + message: data.version ? `Nerd Fonts ${data.version}` : undefined, + }); + return refresh(); +}, { icon: Icons.refresh, shortcut: "<Ctrl>r", section: "Glyphs" }); + +const globalActions: Action[] = [ + updateAction, + open(CHEAT_SHEET, "Open Nerd Fonts Cheat Sheet", { icon: Icons.globe, section: "Glyphs" }), + open(DOWNLOADS, "Download a Nerd Font", { icon: Icons.download, section: "Glyphs" }), +]; + +let summary = ""; + +export default list({ + layout: "grid", + columns: 8, + placeholder: "Search Nerd Font icons: folder, git branch, nf-md-…, f07c", + filter: { options: SETS.map(({ value, title }) => ({ value, title })), default: "all" }, + empty: { + title: "No glyph found", + description: + "Try another word or set. Glyphs show only if your font has them: install a Nerd Font (Ctrl+K → Download a Nerd Font).", + icon: Icons.font, + }, + status: () => summary, + actions: globalActions, + async search(query, { filter }) { + const data = await glyphs(); + const set = filter ?? "all"; + const counts = countBySet(data.glyphs); + const setTitle = SETS.find((s) => s.value === set)?.title ?? "All"; + const about = data.source === "fallback" + ? `Offline: ${data.glyphs.length} popular glyphs` + : `${counts[set].toLocaleString("en-US")} glyphs${set === "all" ? "" : ` in ${setTitle}`}${ + data.version ? ` · Nerd Fonts ${data.version}` : "" + }`; + const items: Item[] = []; + if (data.source === "fallback") { + items.push({ + id: "offline", + title: "Offline", + subtitle: `Could not download the glyph list: ${data.error}`, + icon: { source: Icons.wifiOff, tint: "orange" }, + section: "Glyph list", + detail: { markdown: `Showing ${data.glyphs.length} popular glyphs. ${data.error ?? ""}` }, + actions: [updateAction], + }); + } + if (!query.trim()) { + const names = new Map(data.glyphs.map((g) => [g[0], g])); + const mine = (await recent()).map((n) => names.get(n)).filter((g): g is Glyph => !!g) + .filter((g) => searchGlyphs([g], "", set).length); + items.push(...mine.map((g) => glyphItem(g, "Recently Copied"))); + const browse = set === "all" ? POPULAR.filter((g) => names.has(g[0])) : searchGlyphs(data.glyphs, "", set, 240); + items.push(...browse.map((g) => glyphItem(g, set === "all" ? "Popular" : setTitle))); + summary = about; + return items; + } + const found = searchGlyphs(data.glyphs, query, set); + summary = `${found.length === 240 ? "240+" : found.length} match${found.length === 1 ? "" : "es"} · ${about}`; + items.push(...found.map((g) => glyphItem(g, "Results"))); + return items; + }, +}); diff --git a/extensions/nerd-fonts/fallback.json b/extensions/nerd-fonts/fallback.json new file mode 100644 index 0000000..6f9010b --- /dev/null +++ b/extensions/nerd-fonts/fallback.json @@ -0,0 +1,321 @@ +[ + ["dev-git", "e702"], + ["dev-github", "e709"], + ["dev-github_badge", "e709"], + ["dev-python", "e73c"], + ["dev-rust", "e7a8"], + ["dev-docker", "e7b0"], + ["dev-linux", "e712"], + ["dev-apple", "e711"], + ["dev-windows", "e70f"], + ["dev-javascript", "e781"], + ["dev-typescript", "e8ca"], + ["dev-html5", "e736"], + ["dev-css3", "e749"], + ["dev-react", "e7ba"], + ["dev-nodejs_small", "e718"], + ["dev-go", "e724"], + ["dev-java", "e738"], + ["dev-vim", "e7c5"], + ["dev-terminal", "e795"], + ["dev-npm", "e71e"], + ["dev-kubernetes", "e81d"], + ["dev-aws", "e7ad"], + ["dev-database", "e706"], + ["dev-ruby", "e739"], + ["dev-php", "e73d"], + ["dev-swift", "e755"], + ["dev-kotlin", "e81b"], + ["dev-c", "e771"], + ["dev-cplusplus", "e7a3"], + ["dev-lua", "e826"], + ["dev-dart", "e798"], + ["dev-android", "e70e"], + ["dev-firefox", "e745"], + ["dev-chrome", "e743"], + ["dev-visualstudio", "e70c"], + ["dev-vscode", "e8da"], + ["dev-gitlab", "e7eb"], + ["dev-bitbucket", "e703"], + ["dev-postgresql", "e76e"], + ["dev-mysql", "e704"], + ["dev-redis", "e76d"], + ["dev-mongodb", "e7a4"], + ["dev-sqlite", "e7c4"], + ["dev-markdown", "e73e"], + ["dev-json", "e80b"], + ["dev-yaml", "e8eb"], + ["dev-nixos", "e843"], + ["dev-debian", "e77d"], + ["dev-ubuntu", "e73a"], + ["dev-fedora", "e7d9"], + ["dev-archlinux", "e732"], + ["cod-github", "ea84"], + ["cod-git_branch", "ec6f"], + ["cod-git_commit", "eafc"], + ["cod-git_merge", "eafe"], + ["cod-git_pull_request", "ea64"], + ["cod-terminal", "ea85"], + ["cod-folder", "ea83"], + ["cod-folder_opened", "eaf7"], + ["cod-file", "ea7b"], + ["cod-file_code", "eae9"], + ["cod-search", "ea6d"], + ["cod-settings_gear", "eb51"], + ["cod-check", "eab2"], + ["cod-close", "ea76"], + ["cod-warning", "ea6c"], + ["cod-error", "ea87"], + ["cod-info", "ea74"], + ["cod-bug", "eaaf"], + ["cod-rocket", "eb44"], + ["cod-lightbulb", "ea61"], + ["cod-heart", "eb05"], + ["cod-star_full", "eb59"], + ["cod-calendar", "eab0"], + ["cod-cloud", "ebaa"], + ["cod-cloud_download", "eac2"], + ["cod-cloud_upload", "eac3"], + ["cod-lock", "ea75"], + ["cod-unlock", "eb74"], + ["cod-account", "eb99"], + ["cod-home", "eb06"], + ["cod-bell", "eaa2"], + ["cod-trash", "ea81"], + ["cod-edit", "ea73"], + ["cod-save", "eb4b"], + ["cod-copy", "ebcc"], + ["cod-debug", "ead8"], + ["cod-play", "eb2c"], + ["cod-debug_pause", "ead1"], + ["cod-refresh", "eb37"], + ["cod-sync", "ea77"], + ["cod-extensions", "eae6"], + ["cod-package", "eb29"], + ["cod-database", "eace"], + ["cod-server", "eb50"], + ["cod-key", "eb11"], + ["cod-mail", "eb1c"], + ["cod-link", "eb15"], + ["cod-pin", "eb2b"], + ["cod-tag", "ea66"], + ["cod-eye", "ea70"], + ["cod-code", "eac4"], + ["cod-json", "eb0f"], + ["cod-markdown", "eb1d"], + ["fa-github", "f09b"], + ["fa-gitlab", "f296"], + ["fa-git", "f1d3"], + ["fa-folder", "f07b"], + ["fa-folder_open", "f07c"], + ["fa-file", "f15b"], + ["fa-home", "f015"], + ["fa-search", "f002"], + ["fa-gear", "f013"], + ["fa-check", "f00c"], + ["fa-close", "f00d"], + ["fa-warning", "f071"], + ["fa-info_circle", "f05a"], + ["fa-heart", "f004"], + ["fa-star", "f005"], + ["fa-clock_o", "f017"], + ["fa-calendar", "f073"], + ["fa-battery_full", "f240"], + ["fa-battery_half", "f242"], + ["fa-battery_empty", "f244"], + ["fa-wifi", "f1eb"], + ["fa-bluetooth", "f293"], + ["fa-volume_up", "f028"], + ["fa-volume_off", "f026"], + ["fa-music", "f001"], + ["fa-play", "f04b"], + ["fa-pause", "f04c"], + ["fa-stop", "f04d"], + ["fa-lock", "f023"], + ["fa-user", "f007"], + ["fa-cloud", "f0c2"], + ["fa-download", "f019"], + ["fa-upload", "f093"], + ["fa-code", "f121"], + ["fa-code_fork", "f126"], + ["fa-bug", "f188"], + ["fa-rocket", "f135"], + ["fa-fire", "f06d"], + ["fa-lightbulb_o", "f0eb"], + ["fa-terminal", "f120"], + ["fa-power_off", "f011"], + ["fa-bell", "f0f3"], + ["fa-envelope", "f0e0"], + ["fa-trash", "f1f8"], + ["fa-pencil", "f040"], + ["fa-camera", "f030"], + ["fa-desktop", "f108"], + ["fa-laptop", "f109"], + ["fa-mobile", "ed08"], + ["fa-keyboard_o", "f11c"], + ["fa-microchip", "f2db"], + ["fa-hdd_o", "f0a0"], + ["fa-server", "f233"], + ["fa-database", "f1c0"], + ["fa-plug", "f1e6"], + ["fa-coffee", "f0f4"], + ["fa-linux", "f17c"], + ["fa-apple", "f179"], + ["fa-windows", "f17a"], + ["fa-android", "f17b"], + ["fa-docker", "f21f"], + ["fa-python", "ed1b"], + ["fa-npm", "ed0e"], + ["fa-spotify", "f1bc"], + ["fa-slack", "f198"], + ["fa-discord", "f1ff"], + ["fa-telegram", "f2c6"], + ["fa-reddit", "f1a1"], + ["fa-twitter", "f099"], + ["fa-youtube", "f16a"], + ["fa-firefox", "f269"], + ["fa-chrome", "f268"], + ["linux-archlinux", "f303"], + ["linux-debian", "f306"], + ["linux-ubuntu", "f31b"], + ["linux-fedora", "f30a"], + ["linux-nixos", "f313"], + ["linux-tux", "f31a"], + ["linux-manjaro", "f312"], + ["linux-opensuse", "f314"], + ["linux-gentoo", "f30d"], + ["linux-pop_os", "f32a"], + ["linux-endeavour", "f322"], + ["linux-alpine", "f300"], + ["linux-raspberry_pi", "f315"], + ["linux-redhat", "f316"], + ["linux-centos", "f304"], + ["linux-void", "f32e"], + ["linux-kali_linux", "f327"], + ["linux-elementary", "f309"], + ["linux-gnome", "f361"], + ["linux-kde", "f373"], + ["linux-hyprland", "f359"], + ["linux-sway", "f35d"], + ["linux-i3", "f35a"], + ["linux-neovim", "f36f"], + ["linux-wayland", "f367"], + ["linux-xorg", "f369"], + ["md-folder", "f024b"], + ["md-folder_open", "f0770"], + ["md-file", "f0214"], + ["md-home", "f02dc"], + ["md-magnify", "f0349"], + ["md-cog", "f0493"], + ["md-check", "f012c"], + ["md-close", "f0156"], + ["md-alert", "f0026"], + ["md-heart", "f02d1"], + ["md-star", "f04ce"], + ["md-clock", "f0954"], + ["md-calendar", "f00ed"], + ["md-battery", "f0079"], + ["md-wifi", "f05a9"], + ["md-bluetooth", "f00af"], + ["md-volume_high", "f057e"], + ["md-music", "f075a"], + ["md-play", "f040a"], + ["md-pause", "f03e4"], + ["md-lock", "f033e"], + ["md-account", "f0004"], + ["md-cloud", "f015f"], + ["md-download", "f01da"], + ["md-upload", "f0552"], + ["md-code_braces", "f0169"], + ["md-source_branch", "f062c"], + ["md-bug", "f00e4"], + ["md-rocket", "f0463"], + ["md-fire", "f0238"], + ["md-lightbulb", "f0335"], + ["md-console", "f018d"], + ["md-power", "f0425"], + ["md-bell", "f009a"], + ["md-email", "f01ee"], + ["md-delete", "f01b4"], + ["md-pencil", "f03eb"], + ["md-language_python", "f0320"], + ["md-language_rust", "f1617"], + ["md-language_typescript", "f06e6"], + ["md-language_javascript", "f031e"], + ["md-language_go", "f07d3"], + ["md-docker", "f0868"], + ["md-kubernetes", "f10fe"], + ["md-github", "f02a4"], + ["md-git", "f02a2"], + ["md-npm", "f06f7"], + ["md-nodejs", "f0399"], + ["md-react", "f0708"], + ["md-vuejs", "f0844"], + ["md-linux", "f033d"], + ["md-apple", "f0035"], + ["md-microsoft_windows", "f05b3"], + ["md-ubuntu", "f0548"], + ["md-arch", "f08c7"], + ["md-memory", "f035b"], + ["md-harddisk", "f02ca"], + ["md-thermometer", "f050f"], + ["md-weather_sunny", "f0599"], + ["md-weather_cloudy", "f0590"], + ["md-weather_rainy", "f0597"], + ["md-weather_snowy", "f0598"], + ["md-weather_night", "f0594"], + ["oct-mark_github", "f408"], + ["oct-repo", "f401"], + ["oct-git_branch", "f418"], + ["oct-git_commit", "f417"], + ["oct-git_pull_request", "f407"], + ["oct-issue_opened", "f41b"], + ["oct-check", "f42e"], + ["oct-x", "f467"], + ["oct-alert", "f421"], + ["oct-gear", "f423"], + ["oct-terminal", "f489"], + ["oct-file", "f4a5"], + ["oct-file_directory", "f413"], + ["oct-search", "f422"], + ["oct-star", "f41e"], + ["oct-heart", "2665"], + ["oct-rocket", "f427"], + ["oct-bug", "f46f"], + ["oct-zap", "26a1"], + ["oct-package", "f487"], + ["weather-day_sunny", "e30d"], + ["weather-cloudy", "e312"], + ["weather-rain", "e318"], + ["weather-snow", "e31a"], + ["weather-thunderstorm", "e31d"], + ["weather-fog", "e313"], + ["weather-night_clear", "e32b"], + ["weather-windy", "e31e"], + ["weather-thermometer", "e350"], + ["weather-umbrella", "e37c"], + ["pl-left_hard_divider", "e0b0"], + ["pl-right_hard_divider", "e0b2"], + ["pl-left_soft_divider", "e0b1"], + ["pl-right_soft_divider", "e0b3"], + ["pl-branch", "e0a0"], + ["pl-line_number", "e0a1"], + ["ple-left_half_circle_thick", "e0b6"], + ["ple-right_half_circle_thick", "e0b4"], + ["ple-flame_thick", "e0c0"], + ["ple-pixelated_squares_big", "e0c6"], + ["seti-python", "e606"], + ["seti-rust", "e68b"], + ["seti-go", "e627"], + ["seti-typescript", "e628"], + ["seti-javascript", "e60c"], + ["seti-html", "e60e"], + ["seti-css", "e614"], + ["seti-json", "e60b"], + ["seti-markdown", "e609"], + ["seti-docker", "e650"], + ["seti-git", "e65d"], + ["seti-lua", "e620"], + ["seti-shell", "e691"], + ["seti-config", "e615"] +] diff --git a/extensions/nerd-fonts/glyphs.ts b/extensions/nerd-fonts/glyphs.ts new file mode 100644 index 0000000..54f585b --- /dev/null +++ b/extensions/nerd-fonts/glyphs.ts @@ -0,0 +1,56 @@ +// Loading the glyph list: from storage, else downloaded once from the +// nerd-fonts repository, else the small list shipped with the extension. + +import { storage } from "@backslash/api"; +import { type Glyph, type GlyphData, GLYPHNAMES_URL, isGlyphData, parseGlyphnames, STALE_AFTER } from "./lib.ts"; +import popular from "./fallback.json" with { type: "json" }; + +export const POPULAR = popular as Glyph[]; + +export type Loaded = GlyphData & { + source: "cache" | "download" | "fallback"; + error?: string; +}; + +export async function download(signal?: AbortSignal): Promise<GlyphData> { + const res = await fetch(GLYPHNAMES_URL, { signal }); + if (!res.ok) throw new Error(`GitHub answered ${res.status}`); + const data = parseGlyphnames(await res.text()); + await storage.set("glyphs", data); + return data; +} + +let loading: Promise<Loaded> | undefined; + +/** Loaded once per process; a stale cache is used while a fresh copy downloads. */ +export function glyphs(): Promise<Loaded> { + loading ??= (async () => { + const cached = await storage.get("glyphs").catch(() => undefined); + if (isGlyphData(cached)) { + if (Date.now() - cached.fetched > STALE_AFTER) download().catch(() => {}); + return { ...cached, source: "cache" as const }; + } + try { + return { ...(await download()), source: "download" as const }; + } catch (err) { + loading = undefined; // Try the network again next time. + return { + version: "", + fetched: 0, + glyphs: POPULAR, + source: "fallback" as const, + error: err instanceof Error ? err.message : String(err), + }; + } + })(); + return loading; +} + +export function reset() { + loading = undefined; +} + +export async function recent(): Promise<string[]> { + const r = await storage.get<string[]>("recent").catch(() => undefined); + return Array.isArray(r) ? r : []; +} diff --git a/extensions/nerd-fonts/lib.ts b/extensions/nerd-fonts/lib.ts new file mode 100644 index 0000000..75a8e69 --- /dev/null +++ b/extensions/nerd-fonts/lib.ts @@ -0,0 +1,166 @@ +// Pure logic of the Nerd Font Icons extension: the glyph list, search, +// sets and the text forms of a glyph. No I/O here. + +export const GLYPHNAMES_URL = "https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/glyphnames.json"; + +/** `[name without nf-, hex code]`, e.g. `["md-folder_open", "f0770"]`. */ +export type Glyph = [string, string]; + +// A type alias (not an interface) so that it is plain JSON for storage. +export type GlyphData = { + version: string; + fetched: number; + glyphs: Glyph[]; +}; + +/** Reads the official glyphnames.json into a compact list. */ +export function parseGlyphnames(json: string, now = Date.now()): GlyphData { + const data = JSON.parse(json) as Record<string, { char?: string; code?: string; version?: string }>; + const version = String((data.METADATA as { version?: string } | undefined)?.version ?? ""); + const glyphs: Glyph[] = []; + for (const [name, g] of Object.entries(data)) { + if (name === "METADATA" || !g || typeof g.code !== "string" || !/^[0-9a-f]{4,6}$/i.test(g.code)) continue; + glyphs.push([name, g.code.toLowerCase()]); + } + if (glyphs.length < 100) throw new Error("glyphnames.json looks incomplete"); + return { version, fetched: now, glyphs }; +} + +export function isGlyphData(v: unknown): v is GlyphData { + const d = v as GlyphData; + return !!d && typeof d === "object" && Array.isArray(d.glyphs) && d.glyphs.length > 0 && + typeof d.fetched === "number"; +} + +/** Refreshed monthly: Nerd Fonts releases a few times a year. */ +export const STALE_AFTER = 30 * 86400 * 1000; + +// --- Sets --------------------------------------------------------------------------- + +export interface GlyphSet { + value: string; + title: string; + prefixes: string[]; +} + +export const SETS: GlyphSet[] = [ + { value: "all", title: "All", prefixes: [] }, + { value: "md", title: "Material", prefixes: ["md"] }, + { value: "fa", title: "Font Awesome", prefixes: ["fa", "fae"] }, + { value: "dev", title: "Devicons", prefixes: ["dev"] }, + { value: "cod", title: "Codicons", prefixes: ["cod"] }, + { value: "oct", title: "Octicons", prefixes: ["oct"] }, + { value: "linux", title: "Logos", prefixes: ["linux"] }, + { value: "seti", title: "Seti", prefixes: ["seti", "custom"] }, + { value: "weather", title: "Weather", prefixes: ["weather"] }, + { value: "pl", title: "Powerline", prefixes: ["pl", "ple", "iec", "indent", "indentation", "extra", "pom"] }, +]; + +export const SET_NAMES: Record<string, string> = { + md: "Material Design", + fa: "Font Awesome", + fae: "Font Awesome Extension", + dev: "Devicons", + cod: "Codicons", + oct: "Octicons", + linux: "Font Logos", + seti: "Seti UI", + custom: "Seti UI + Custom", + weather: "Weather Icons", + pl: "Powerline", + ple: "Powerline Extra", + iec: "IEC Power Symbols", + indent: "Indent", + indentation: "Indent", + extra: "Extra", + pom: "Pomicons", +}; + +export const prefixOf = (name: string) => name.split("-")[0]; + +export function inSet(name: string, set: string | undefined): boolean { + const s = SETS.find((x) => x.value === set); + return !s || !s.prefixes.length || s.prefixes.includes(prefixOf(name)); +} + +// --- Text forms ------------------------------------------------------------------------ + +export const charOf = (code: string) => String.fromCodePoint(parseInt(code, 16)); +export const className = (name: string) => `nf-${name}`; +export const codePoint = (code: string) => `U+${code.toUpperCase()}`; +export const htmlEntity = (code: string) => `&#x${code};`; +export const cssContent = (code: string) => `\\${code}`; +/** ``, or `\u{f0770}` beyond the BMP (JavaScript, Rust, Python 3 use their own forms). */ +export const jsEscape = (code: string) => code.length > 4 ? `\\u{${code}}` : `\\u${code}`; + +/** `md-folder_open` → `folder open`. */ +export const shortTitle = (name: string) => name.slice(name.indexOf("-") + 1).replace(/_/g, " "); + +// --- Search ------------------------------------------------------------------------------ + +/** + * Finds glyphs by name words (`folder open`, `nf-md-folder`), by code point + * (`f07c`, `U+F07C`, ``) or by the glyph itself. Best matches first. + */ +export function searchGlyphs(glyphs: Glyph[], query: string, set?: string, limit = 240): Glyph[] { + const q = query.trim().toLowerCase().replace(/^nf-/, ""); + if (!q) return glyphs.filter((g) => inSet(g[0], set)).slice(0, limit); + + // The glyph itself, pasted. + const chars = [...q]; + if (chars.length === 1 && q.codePointAt(0)! >= 0xe000) { + const code = q.codePointAt(0)!.toString(16); + return glyphs.filter((g) => g[1] === code); + } + const hex = /^(?:u\+|\\u\{?|0x|&#x)?([0-9a-f]{4,6})\}?;?$/.exec(q)?.[1]; + + const tokens = q.split(/[\s_\-]+/).filter(Boolean); + const scored: { g: Glyph; score: number }[] = []; + for (const g of glyphs) { + if (!inSet(g[0], set)) continue; + const name = g[0]; + if (hex && g[1] === hex) { + scored.push({ g, score: -1 }); + continue; + } + const bare = name.slice(name.indexOf("-") + 1); + const words = name.split(/[-_]/); + let score: number; + if (name === q || bare === q.replace(/ /g, "_")) score = 0; + else if (bare.startsWith(q.replace(/ /g, "_"))) score = 1; + else { + // Every token must start a word (best) or appear in the name. + let total = 2; + let ok = true; + for (const t of tokens) { + if (words.some((w) => w === t)) continue; + if (words.some((w) => w.startsWith(t))) total += 1; + else if (name.includes(t)) total += 3; + else { + ok = false; + break; + } + } + if (!ok) continue; + score = total; + } + scored.push({ g, score }); + } + scored.sort((a, b) => a.score - b.score || a.g[0].length - b.g[0].length || a.g[0].localeCompare(b.g[0])); + return scored.slice(0, limit).map((s) => s.g); +} + +/** Counts per set, for the status line. */ +export function countBySet(glyphs: Glyph[]): Record<string, number> { + const out: Record<string, number> = {}; + for (const s of SETS) out[s.value] = 0; + for (const g of glyphs) { + for (const s of SETS) if (s.value === "all" || s.prefixes.includes(prefixOf(g[0]))) out[s.value]++; + } + return out; +} + +/** Most recent first, no duplicates, at most `max`. */ +export function remember(recent: string[], name: string, max = 24): string[] { + return [name, ...recent.filter((n) => n !== name)].slice(0, max); +} diff --git a/extensions/nerd-fonts/lib_test.ts b/extensions/nerd-fonts/lib_test.ts new file mode 100644 index 0000000..e91fc64 --- /dev/null +++ b/extensions/nerd-fonts/lib_test.ts @@ -0,0 +1,116 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + charOf, + className, + codePoint, + countBySet, + cssContent, + type Glyph, + htmlEntity, + inSet, + isGlyphData, + jsEscape, + parseGlyphnames, + remember, + searchGlyphs, + shortTitle, +} from "./lib.ts"; +import fallback from "./fallback.json" with { type: "json" }; + +// A sample of the official glyphnames.json (the real file is ~550 KB). +const SAMPLE: Record<string, unknown> = { + METADATA: { website: "https://www.nerdfonts.com", version: "3.5.1", date: "2026-08-21 13:56:47+00:00" }, + "cod-folder_opened": { char: "", code: "eaf7" }, + "custom-folder_open": { char: "", code: "e5fe" }, + "dev-git": { char: "", code: "e702" }, + "dev-archlinux": { char: "", code: "e732" }, + "fa-folder_open": { char: "", code: "f07c" }, + "fa-folder_open_o": { char: "", code: "f115" }, + "fa-git": { char: "", code: "f1d3" }, + "linux-archlinux": { char: "", code: "f303" }, + "md-arch": { char: "\u{f08c7}", code: "f08c7" }, + "md-folder_open": { char: "\u{f0770}", code: "f0770" }, + "md-folder_lock_open": { char: "\u{f0250}", code: "f0250" }, + "md-source_branch": { char: "\u{f062c}", code: "f062c" }, + "oct-git_branch": { char: "", code: "f418" }, + "pl-branch": { char: "", code: "e0a0" }, + "weather-day_sunny": { char: "", code: "e30d" }, +}; +for (let i = 0; i < 100; i++) SAMPLE[`md-filler_${i}`] = { char: "x", code: (0xf1000 + i).toString(16) }; +SAMPLE["broken"] = { char: "?" }; + +const DATA = parseGlyphnames(JSON.stringify(SAMPLE), 1000); +const G = DATA.glyphs; +const names = (gs: Glyph[]) => gs.map((g) => g[0]); + +Deno.test("glyphnames.json becomes a compact list", () => { + assertEquals(DATA.version, "3.5.1"); + assertEquals(DATA.fetched, 1000); + assertEquals(G.length, 115); + assertEquals(G.find((g) => g[0] === "md-folder_open"), ["md-folder_open", "f0770"]); + assertEquals(isGlyphData(DATA), true); + assertEquals(isGlyphData({ glyphs: [] }), false); + assertEquals(isGlyphData(undefined), false); + assertThrows(() => parseGlyphnames('{"METADATA":{}}'), Error, "incomplete"); +}); + +Deno.test("search by words, best matches first", () => { + assertEquals(names(searchGlyphs(G, "folder open")), [ + "fa-folder_open", + "md-folder_open", + "custom-folder_open", + "fa-folder_open_o", + "cod-folder_opened", + "md-folder_lock_open", + ]); + assertEquals(names(searchGlyphs(G, "nf-md-folder_open"))[0], "md-folder_open"); + assertEquals(names(searchGlyphs(G, "branch")).slice(0, 3), ["pl-branch", "oct-git_branch", "md-source_branch"]); + assertEquals(names(searchGlyphs(G, "arch")).slice(0, 3), ["md-arch", "dev-archlinux", "linux-archlinux"]); + assertEquals(searchGlyphs(G, "nothing-like-this"), []); +}); + +Deno.test("search by code point or glyph", () => { + for (const q of ["f07c", "U+F07C", "\\uf07c", "0xf07c", ""]) { + assertEquals(names(searchGlyphs(G, q)), ["fa-folder_open"], q); + } + assertEquals(names(searchGlyphs(G, "\\u{f0770}")), ["md-folder_open"]); + assertEquals(names(searchGlyphs(G, "")), ["fa-git"]); +}); + +Deno.test("sets", () => { + assertEquals(names(searchGlyphs(G, "folder open", "md")), ["md-folder_open", "md-folder_lock_open"]); + assertEquals(names(searchGlyphs(G, "", "linux")), ["linux-archlinux"]); + assertEquals(searchGlyphs(G, "", "all", 3).length, 3); + assertEquals(inSet("fae-tooth", "fa"), true); + assertEquals(inSet("ple-flame_thick", "pl"), true); + assertEquals(inSet("md-x", undefined), true); + const c = countBySet(G); + assertEquals([c.all, c.md, c.fa, c.dev], [115, 104, 3, 2]); +}); + +Deno.test("text forms of a glyph", () => { + assertEquals(charOf("f07c"), ""); + assertEquals(charOf("f0770"), "\u{f0770}"); + assertEquals(className("md-folder_open"), "nf-md-folder_open"); + assertEquals(codePoint("f0770"), "U+F0770"); + assertEquals(htmlEntity("f07c"), ""); + assertEquals(cssContent("f07c"), "\\f07c"); + assertEquals(jsEscape("f07c"), "\\uf07c"); + assertEquals(jsEscape("f0770"), "\\u{f0770}"); + assertEquals(shortTitle("md-folder_open"), "folder open"); +}); + +Deno.test("recently copied", () => { + assertEquals(remember(["a", "b", "c"], "b"), ["b", "a", "c"]); + assertEquals(remember(["a", "b"], "c", 2), ["c", "a"]); +}); + +Deno.test("the shipped fallback is valid", () => { + const list = fallback as Glyph[]; + assertEquals(list.length > 200, true); + for (const [name, code] of list) { + assertEquals(/^[a-z]+-[\w]+$/.test(name), true, name); + assertEquals(/^[0-9a-f]{4,5}$/.test(code), true, code); + } + assertEquals(new Set(list.map((g) => g[0])).size, list.length); +}); diff --git a/extensions/network-tools/backslash.toml b/extensions/network-tools/backslash.toml new file mode 100644 index 0000000..58b780f --- /dev/null +++ b/extensions/network-tools/backslash.toml @@ -0,0 +1,99 @@ +[extension] +id = "network-tools" +title = "Network Tools" +description = "Public IP and location, local interfaces, DNS lookup, whois, ping and a speed test" +version = "1.0.0" +author = "Backslash" +icon = "bs-globe" +license = "MIT" + +# api.ipify.org / api6.ipify.org tell the public IPv4 / IPv6, ipinfo.io where +# it is; DNS goes over HTTPS to Cloudflare (dig and the system resolver when +# chosen, or when Cloudflare can't be reached); speed.cloudflare.com serves +# the speed test. ip lists interfaces, ping and whois do what they say. +[permissions] +net = ["api.ipify.org", "api6.ipify.org", "ipinfo.io", "cloudflare-dns.com", "speed.cloudflare.com"] +run = ["ip", "ping", "whois", "dig"] +read = ["/etc/os-release"] + +[[commands]] +name = "network-tools" +title = "Network Tools" +description = "Type dns example.com, ping 1.1.1.1, whois x.org, speed or ip" +icon = "bs-globe" +keywords = ["network", "réseau", "ip", "dns", "ping", "whois", "speed test", "outils réseau", "lookup", "internet"] + +[[commands]] +name = "public-ip" +title = "Public IP Address" +description = "Your public IPv4 and IPv6, with their location and provider" +icon = "bs-globe" +keywords = ["ip", "public ip", "my ip", "adresse ip", "ip publique", "wan", "geolocation", "géolocalisation"] + +[[commands]] +name = "copy-ip" +title = "Copy Public IP" +description = "Copies your public IP address" +icon = "bs-copy" +mode = "no-view" +keywords = ["ip", "copy", "copier", "public ip", "ip publique", "my ip"] + +[[commands]] +name = "interfaces" +title = "Network Interfaces" +description = "Local addresses, gateway, MAC and traffic of each interface" +icon = "bs-plug" +keywords = ["interfaces", "ifconfig", "ip addr", "local ip", "ip locale", "lan", "mac", "carte réseau", "ethernet"] + +[[commands]] +name = "dns" +title = "DNS Lookup" +description = "A, AAAA, MX, TXT, NS, CNAME… records of a domain" +icon = "bs-search" +keywords = ["dns", "dig", "nslookup", "lookup", "mx", "txt", "records", "enregistrements", "domaine", "résolution"] + +[[commands]] +name = "ping" +title = "Ping" +description = "Latency and packet loss to a host" +icon = "bs-activity" +keywords = ["ping", "latency", "latence", "packet loss", "perte de paquets", "icmp"] +[[commands.arguments]] +name = "host" +placeholder = "1.1.1.1" + +[[commands]] +name = "whois" +title = "Whois" +description = "Registrar, dates and name servers of a domain, or who owns an IP" +icon = "bs-shield" +keywords = ["whois", "domain", "domaine", "registrar", "expiry", "expiration", "owner", "propriétaire"] +[[commands.arguments]] +name = "domain" +placeholder = "example.com" +required = true + +[[commands]] +name = "speed-test" +title = "Internet Speed Test" +description = "Download, upload, latency and jitter (speed.cloudflare.com)" +icon = "bs-rocket" +keywords = ["speed", "speedtest", "test de débit", "débit", "bandwidth", "bande passante", "download", "upload", "vitesse"] + +[[preferences]] +name = "resolver" +title = "DNS resolver" +description = "cloudflare (DNS over HTTPS, default) or system (dig, your network's resolver)" +default = "cloudflare" + +[[preferences]] +name = "pingCount" +title = "Pings per run" +type = "number" +default = 4 + +[[preferences]] +name = "ipinfoToken" +title = "ipinfo.io token" +description = "Optional: lifts the free limit of 50 000 lookups a month (ipinfo.io/signup)" +type = "secret" diff --git a/extensions/network-tools/commands/copy-ip.ts b/extensions/network-tools/commands/copy-ip.ts new file mode 100644 index 0000000..ff3186f --- /dev/null +++ b/extensions/network-tools/commands/copy-ip.ts @@ -0,0 +1,10 @@ +import { clipboard, run } from "@backslash/api"; +import { publicIps } from "../system.ts"; + +export default run(async () => { + const ips = await publicIps(); + const ip = ips.v4 ?? ips.v6; + if (!ip) throw new Error(`No public IP: ${ips.error ?? "offline?"}`); + await clipboard.write(ip); + return `Copied ${ip}`; +}); diff --git a/extensions/network-tools/commands/dns.ts b/extensions/network-tools/commands/dns.ts new file mode 100644 index 0000000..ca2ffe9 --- /dev/null +++ b/extensions/network-tools/commands/dns.ts @@ -0,0 +1,33 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { cleanHost, defaultTypes, isIP, RECORD_TYPES, type RecordType } from "../lib.ts"; +import { dnsResultItems } from "../ui.ts"; + +// "example.com", "example.com mx txt", "1.1.1.1" (reverse). +export default (_: Context) => + list({ + placeholder: "example.com · example.com mx txt · 1.1.1.1", + status: (items) => + items.length ? `${items.filter((i) => i.id?.startsWith("dns:") && i.id !== "dns:none").length} records` : "", + empty: { + title: "Type a domain name", + description: "Add record types after it: example.com mx txt", + icon: Icons.search, + }, + search(query, ctx) { + const [first, ...rest] = query.trim().split(/\s+/); + const host = first ? cleanHost(first) : undefined; + if (!host) { + return first + ? [{ + id: "invalid", + title: `“${first}” isn't a host name or an IP address`, + icon: { source: Icons.info, tint: "gray" }, + }] + : []; + } + const types = rest.map((t) => t.toUpperCase()).filter((t): t is RecordType => + (RECORD_TYPES as readonly string[]).includes(t) + ); + return dnsResultItems(host, types.length ? types : isIP(host) ? ["PTR"] : defaultTypes, ctx); + }, + }); diff --git a/extensions/network-tools/commands/interfaces.ts b/extensions/network-tools/commands/interfaces.ts new file mode 100644 index 0000000..ec4dde8 --- /dev/null +++ b/extensions/network-tools/commands/interfaces.ts @@ -0,0 +1,9 @@ +import { Icons, list } from "@backslash/api"; +import { interfaceItems } from "../ui.ts"; + +export default list({ + placeholder: "Filter interfaces and addresses…", + status: (items) => `${items.filter((i) => i.section === "Up").length} up · ${items.length} interfaces`, + empty: { title: "No network interface", icon: Icons.plug }, + items: () => interfaceItems(), +}); diff --git a/extensions/network-tools/commands/network-tools.ts b/extensions/network-tools/commands/network-tools.ts new file mode 100644 index 0000000..c98e978 --- /dev/null +++ b/extensions/network-tools/commands/network-tools.ts @@ -0,0 +1,165 @@ +import { action, type Context, Icons, type Item, list } from "@backslash/api"; +import { defaultTypes, formatSpeed, isIP, parseIntent } from "../lib.ts"; +import { + dnsResultItems, + dnsView, + interfaceItems, + lastSpeed, + pingView, + publicIpItems, + speedLine, + speedView, + whoisItems, + whoisView, +} from "../ui.ts"; + +const TOOLS: { id: string; title: string; example: string; icon: string; about: string }[] = [ + { + id: "dns", + title: "DNS Lookup", + example: "dns example.com mx", + icon: Icons.search, + about: "A, AAAA, CNAME, MX, NS and TXT records, or the types you name (SOA, CAA, SRV, PTR).", + }, + { + id: "ping", + title: "Ping", + example: "ping 1.1.1.1", + icon: Icons.activity, + about: "Round-trip times, jitter and packet loss.", + }, + { + id: "whois", + title: "Whois", + example: "whois example.com", + icon: Icons.shield, + about: "Registrar, dates and name servers of a domain, or the network an IP belongs to.", + }, + { + id: "ip", + title: "Public IP", + example: "ip", + icon: Icons.globe, + about: "Your public IPv4 and IPv6 and where they are.", + }, + { + id: "interfaces", + title: "Local Interfaces", + example: "interfaces", + icon: Icons.plug, + about: "Addresses, gateways and traffic of your network interfaces.", + }, +]; + +function toolItems(speed: string | undefined): Item[] { + return [ + { + id: "tool:speed", + title: "Speed Test", + subtitle: speed ? `Last: ${speed}` : "Download, upload and latency", + icon: { source: Icons.rocket, tint: "orange" }, + section: "Tools", + keywords: ["speed", "speedtest", "débit", "bandwidth"], + detail: { + markdown: + "Measures download and upload with Cloudflare's speed test servers. Takes about 20 seconds and transfers up to a few hundred megabytes on fast connections.", + }, + actions: [action("Start Speed Test", () => speedView(), { icon: Icons.rocket })], + }, + ...TOOLS.map((t): Item => ({ + id: `tool:${t.id}`, + title: t.title, + subtitle: `Type “${t.example}”`, + icon: { source: t.icon, tint: "blue" }, + section: "Tools", + keywords: [t.id, t.example], + detail: { markdown: `${t.about}\n\nType \`${t.example}\` in the search bar.` }, + })), + ]; +} + +export default (ctx: Context) => + list({ + placeholder: "dns example.com · ping 1.1.1.1 · whois x.org · speed · ip", + status: (items) => items.find((i) => i.section === "Public IP")?.title ?? "Network Tools", + async search(query, c) { + const intent = parseIntent(query); + const host = "host" in intent ? intent.host : ""; + switch (intent.kind) { + case "home": { + const last = await lastSpeed(); + const [ips, ifs] = await Promise.all([ + publicIpItems(c), + interfaceItems("Local Network", false), + ]); + return [...ips, ...ifs, ...toolItems(last ? speedLine(last) : undefined)]; + } + case "ip": + return publicIpItems(c); + case "interfaces": + return interfaceItems(); + case "speed": { + const last = await lastSpeed(); + return [{ + id: "speed", + title: "Start Speed Test", + subtitle: last ? `Last: ↓ ${formatSpeed(last.down)} · ↑ ${formatSpeed(last.up)}` : "About 20 seconds", + icon: { source: Icons.rocket, tint: "orange" }, + actions: [action("Start Speed Test", () => speedView(), { icon: Icons.rocket })], + }]; + } + case "dns": + return dnsResultItems(intent.host, intent.types, c); + case "whois": + return whoisItems(intent.host, c); + case "ping": + return [{ + id: `ping:${host}`, + title: `Ping ${host}`, + subtitle: "Press Enter to start", + icon: { source: Icons.activity, tint: "blue" }, + actions: [action(`Ping ${host}`, () => pingView(host, ctx), { icon: Icons.activity })], + }]; + case "host": { + const tools: Item[] = [ + { + id: `ping:${host}`, + title: `Ping ${host}`, + icon: { source: Icons.activity, tint: "blue" }, + section: "Tools", + actions: [action(`Ping ${host}`, () => pingView(host, ctx), { icon: Icons.activity })], + }, + { + id: `whois:${host}`, + title: `Whois ${host}`, + icon: { source: Icons.shield, tint: "blue" }, + section: "Tools", + actions: [action(`Whois ${host}`, () => whoisView(host, ctx), { icon: Icons.shield })], + }, + { + id: `dns:${host}`, + title: `All DNS Records of ${host}`, + icon: { source: Icons.search, tint: "blue" }, + section: "Tools", + actions: [ + action("Look Up", () => dnsView(host, isIP(host) ? ["PTR"] : [...defaultTypes, "SOA", "CAA"], ctx), { + icon: Icons.search, + }), + ], + }, + ]; + const records = await dnsResultItems(host, isIP(host) ? ["PTR"] : ["A", "AAAA", "CNAME"], c).catch(() => []); + return [...records, ...tools]; + } + case "invalid": + return [{ + id: "invalid", + title: intent.text + ? `“${intent.text}” isn't a host name or an IP address` + : `Type a host after “${intent.tool}”`, + subtitle: `e.g. ${intent.tool} example.com`, + icon: { source: Icons.info, tint: "gray" }, + }]; + } + }, + }); diff --git a/extensions/network-tools/commands/ping.ts b/extensions/network-tools/commands/ping.ts new file mode 100644 index 0000000..59c9206 --- /dev/null +++ b/extensions/network-tools/commands/ping.ts @@ -0,0 +1,10 @@ +import type { Context } from "@backslash/api"; +import { cleanHost } from "../lib.ts"; +import { pingView } from "../ui.ts"; + +export default (ctx: Context<{ host?: string }>) => { + const text = String(ctx.args.host ?? "").trim() || ctx.query.trim() || "1.1.1.1"; + const host = cleanHost(text); + if (!host) throw new Error(`“${text}” isn't a host name or an IP address`); + return pingView(host, ctx as Context); +}; diff --git a/extensions/network-tools/commands/public-ip.ts b/extensions/network-tools/commands/public-ip.ts new file mode 100644 index 0000000..8478cac --- /dev/null +++ b/extensions/network-tools/commands/public-ip.ts @@ -0,0 +1,11 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { publicIpItems } from "../ui.ts"; + +export default (ctx: Context) => + list({ + placeholder: "Public IP addresses…", + status: (items) => + items.length ? `${items.filter((i) => i.id?.startsWith("ip:")).length} public addresses · ipinfo.io` : "", + empty: { title: "No public IP", description: "Are you offline?", icon: Icons.wifiOff }, + items: () => publicIpItems(ctx, "Public IP"), + }); diff --git a/extensions/network-tools/commands/speed-test.ts b/extensions/network-tools/commands/speed-test.ts new file mode 100644 index 0000000..52e8efe --- /dev/null +++ b/extensions/network-tools/commands/speed-test.ts @@ -0,0 +1,4 @@ +import { speedView } from "../ui.ts"; + +// Starts measuring as soon as it opens. +export default () => speedView(); diff --git a/extensions/network-tools/commands/whois.ts b/extensions/network-tools/commands/whois.ts new file mode 100644 index 0000000..ee97a4d --- /dev/null +++ b/extensions/network-tools/commands/whois.ts @@ -0,0 +1,10 @@ +import type { Context } from "@backslash/api"; +import { cleanHost } from "../lib.ts"; +import { whoisView } from "../ui.ts"; + +export default (ctx: Context<{ domain?: string }>) => { + const text = String(ctx.args.domain ?? "").trim() || ctx.query.trim(); + const host = cleanHost(text); + if (!host) throw new Error(`“${text}” isn't a domain name or an IP address`); + return whoisView(host, ctx as Context); +}; diff --git a/extensions/network-tools/lib.ts b/extensions/network-tools/lib.ts new file mode 100644 index 0000000..cde8db9 --- /dev/null +++ b/extensions/network-tools/lib.ts @@ -0,0 +1,617 @@ +// Parsing what network programs and services answer, and what the user typed. +// Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Addresses and hosts + +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; + +export const isIPv4 = (s: string) => IPV4.test(s); + +export function isIPv6(s: string): boolean { + if (!/^[0-9a-f:.]+$/i.test(s) || !s.includes(":")) return false; + const v4tail = s.match(/:(\d+\.\d+\.\d+\.\d+)$/); + if (v4tail && !isIPv4(v4tail[1])) return false; + const body = v4tail ? s.slice(0, -v4tail[1].length) + "0:0" : s; + const halves = body.split("::"); + if (halves.length > 2) return false; + const groups = halves.flatMap((h) => (h ? h.split(":") : [])); + if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return false; + return halves.length === 2 ? groups.length < 8 : groups.length === 8; +} + +export const isIP = (s: string) => isIPv4(s) || isIPv6(s); + +/** A host name: labels of letters, digits and hyphens, not starting with one (no option injection). */ +export function isHostname(s: string): boolean { + if (s.length > 253 || !s.includes(".") && s !== "localhost") return false; + return s.replace(/\.$/, "").split(".").every((l) => /^(?!-)[a-z0-9_-]{1,63}(?<!-)$/i.test(l)) && + !/^\d+(\.\d+)*$/.test(s); +} + +/** "https://www.example.com/path" → "www.example.com"; undefined when it's not a host or an IP. */ +export function cleanHost(input: string): string | undefined { + let s = input.trim(); + try { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = new URL(s).hostname; + } catch { + return undefined; + } + s = s.replace(/^\[|\]$/g, "").replace(/\/.*$/, ""); + if (!isIPv6(s)) s = s.replace(/:\d+$/, ""); + s = s.toLowerCase(); + return isIP(s) || isHostname(s) ? s : undefined; +} + +/** 192.0.2.10 → private, loopback… (undefined for public addresses). */ +export function addressScope(ip: string): string | undefined { + if (isIPv4(ip)) { + const [a, b] = ip.split(".").map(Number); + if (a === 127) return "Loopback"; + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return "Private"; + if (a === 169 && b === 254) return "Link-local"; + if (a === 100 && b >= 64 && b <= 127) return "Carrier-grade NAT"; + if (a === 192 && b === 0) return "Documentation"; + return undefined; + } + const l = ip.toLowerCase(); + if (l === "::1") return "Loopback"; + if (/^fe[89ab]/.test(l)) return "Link-local"; + if (/^f[cd]/.test(l)) return "Unique local"; + return undefined; +} + +/** The PTR name of an address: 1.2.3.4 → 4.3.2.1.in-addr.arpa. */ +export function reverseName(ip: string): string { + if (isIPv4(ip)) return `${ip.split(".").reverse().join(".")}.in-addr.arpa`; + return `${expandIPv6(ip).replace(/:/g, "").split("").reverse().join(".")}.ip6.arpa`; +} + +export function expandIPv6(ip: string): string { + const [head, tail = ""] = ip.toLowerCase().split("::"); + const h = head ? head.split(":") : []; + const t = ip.includes("::") ? (tail ? tail.split(":") : []) : []; + const fill = ip.includes("::") ? Array(8 - h.length - t.length).fill("0") : []; + return [...h, ...fill, ...t].map((g) => g.padStart(4, "0")).join(":"); +} + +// --------------------------------------------------------------------------- +// What the user typed + +export const RECORD_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA", "CAA", "SRV", "PTR"] as const; +export type RecordType = typeof RECORD_TYPES[number]; + +export type Intent = + | { kind: "home" } + | { kind: "ip" } + | { kind: "interfaces" } + | { kind: "speed" } + | { kind: "dns"; host: string; types: RecordType[] } + | { kind: "ping"; host: string } + | { kind: "whois"; host: string } + | { kind: "host"; host: string } + | { kind: "invalid"; tool: "dns" | "ping" | "whois"; text: string }; + +/** "dns example.com mx", "ping 1.1.1.1", "whois x.org", "speed", "ip", or a bare host. */ +export function parseIntent(query: string): Intent { + const words = query.trim().split(/\s+/).filter(Boolean); + if (!words.length) return { kind: "home" }; + const [first, ...rest] = words; + const cmd = first.toLowerCase(); + if (/^(?:ip|myip|my-ip|public|publicip|ipv4|ipv6|wan)$/.test(cmd) && !rest.length) return { kind: "ip" }; + if (/^(?:interfaces?|ifconfig|addr|local|lan|nic|interfaces?réseau)$/.test(cmd) && !rest.length) { + return { kind: "interfaces" }; + } + if (/^(?:speed|speedtest|speed-test|débit|debit|bandwidth)$/.test(cmd)) return { kind: "speed" }; + if (/^(?:dns|dig|nslookup|lookup|host|resolve)$/.test(cmd)) { + const host = rest[0] ? cleanHost(rest[0]) : undefined; + if (!host) return { kind: "invalid", tool: "dns", text: rest.join(" ") }; + const types = rest.slice(1).map((t) => t.toUpperCase()).filter((t): t is RecordType => + (RECORD_TYPES as readonly string[]).includes(t) + ); + return { kind: "dns", host, types: types.length ? types : isIP(host) ? ["PTR"] : defaultTypes }; + } + if (/^(?:ping|latency|latence)$/.test(cmd)) { + const host = rest[0] ? cleanHost(rest[0]) : undefined; + return host ? { kind: "ping", host } : { kind: "invalid", tool: "ping", text: rest.join(" ") }; + } + if (/^(?:whois|rdap|registrar)$/.test(cmd)) { + const host = rest[0] ? cleanHost(rest[0]) : undefined; + return host ? { kind: "whois", host } : { kind: "invalid", tool: "whois", text: rest.join(" ") }; + } + const host = words.length === 1 ? cleanHost(first) : undefined; + if (host) return { kind: "host", host }; + return { kind: "home" }; +} + +export const defaultTypes: RecordType[] = ["A", "AAAA", "CNAME", "MX", "NS", "TXT"]; + +// --------------------------------------------------------------------------- +// DNS over HTTPS (application/dns-json) + +const TYPE_NUMBERS: Record<number, string> = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 15: "MX", + 16: "TXT", + 28: "AAAA", + 33: "SRV", + 65: "HTTPS", + 257: "CAA", +}; + +export const RCODES: Record<number, string> = { + 0: "NOERROR", + 1: "FORMERR", + 2: "SERVFAIL", + 3: "NXDOMAIN", + 4: "NOTIMP", + 5: "REFUSED", +}; + +export interface DnsRecord { + name: string; + type: string; + ttl: number; + data: string; + /** MX preference, SRV priority. */ + priority?: number; +} + +export interface DnsAnswer { + type: RecordType; + status: string; + records: DnsRecord[]; +} + +/** TXT data comes quoted, sometimes in several chunks: "v=spf1 " "include:…". */ +export function unquoteTxt(data: string): string { + const chunks = [...data.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1")); + return chunks.length ? chunks.join("") : data; +} + +export function parseDoh(type: RecordType, json: unknown): DnsAnswer { + const j = json as { Status?: number; Answer?: { name: string; type: number; TTL: number; data: string }[] }; + const records = (j.Answer ?? []).map((a): DnsRecord => { + const t = TYPE_NUMBERS[a.type] ?? String(a.type); + let data = a.data; + let priority: number | undefined; + if (t === "TXT") data = unquoteTxt(data); + if (t === "MX") { + const m = data.match(/^(\d+)\s+(.*)$/); + if (m) [priority, data] = [Number(m[1]), m[2]]; + } + return { + name: a.name.replace(/\.$/, ""), + type: t, + ttl: a.TTL, + data: t === "TXT" ? data : data.replace(/\.$/, ""), + ...(priority !== undefined ? { priority } : {}), + }; + }); + // CNAMEs come along with the A answers; keep only what was asked, CNAMEs stay for CNAME. + return { + type, + status: RCODES[j.Status ?? 0] ?? `RCODE ${j.Status}`, + records: records.filter((r) => r.type === type).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)), + }; +} + +/** `dig +noall +answer example.com MX` lines → records. */ +export function parseDig(type: RecordType, out: string): DnsAnswer { + const records = out.split("\n").flatMap((line): DnsRecord[] => { + const m = line.trim().match(/^(\S+)\s+(\d+)\s+IN\s+(\S+)\s+(.*)$/); + if (!m || m[3] !== type) return []; + let data = m[4].trim(); + let priority: number | undefined; + if (type === "TXT") data = unquoteTxt(data); + if (type === "MX") { + const p = data.match(/^(\d+)\s+(.*)$/); + if (p) [priority, data] = [Number(p[1]), p[2]]; + } + return [{ + name: m[1].replace(/\.$/, ""), + type, + ttl: Number(m[2]), + data: type === "TXT" ? data : data.replace(/\.$/, ""), + ...(priority !== undefined ? { priority } : {}), + }]; + }); + const status = out.match(/status:\s*(\w+)/)?.[1] ?? "NOERROR"; + return { type, status, records: records.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) }; +} + +export function formatTtl(seconds: number): string { + if (seconds < 60) return `${seconds} s`; + if (seconds < 3600) return `${Math.round(seconds / 60)} min`; + if (seconds < 86400) return `${Math.round(seconds / 3600)} h`; + return `${Math.round(seconds / 86400)} d`; +} + +// --------------------------------------------------------------------------- +// Ping + +export interface PingReply { + seq: number; + ttl?: number; + time: number; +} + +export interface PingStats { + host: string; + address?: string; + replies: PingReply[]; + transmitted?: number; + received?: number; + /** 0 to 100. */ + loss?: number; + min?: number; + avg?: number; + max?: number; + mdev?: number; + errors: string[]; +} + +/** iputils, inetutils and BusyBox ping output (complete or partial, as it streams). */ +export function parsePing(host: string, out: string): PingStats { + const s: PingStats = { host, replies: [], errors: [] }; + for (const line of out.split("\n")) { + const head = line.match(/^PING\s+(\S+)\s+\(([^)]+)\)/); + if (head) s.address = head[2]; + const reply = line.match(/icmp_[sr]eq=(\d+)(?:.*?ttl=(\d+))?.*?time[=<]([\d.]+)\s*ms/i); + if (reply) { + s.replies.push({ seq: Number(reply[1]), ttl: reply[2] ? Number(reply[2]) : undefined, time: Number(reply[3]) }); + } + const sum = line.match( + /(\d+) packets transmitted, (\d+) (?:packets )?received(?:, \+\d+ errors)?, ([\d.]+)% packet loss/, + ); + if (sum) [s.transmitted, s.received, s.loss] = [Number(sum[1]), Number(sum[2]), Number(sum[3])]; + const rtt = line.match( + /(?:rtt|round-trip) min\/avg\/max(?:\/(?:mdev|stddev))? = ([\d.]+)\/([\d.]+)\/([\d.]+)(?:\/([\d.]+))?/, + ); + if (rtt) { + [s.min, s.avg, s.max] = [Number(rtt[1]), Number(rtt[2]), Number(rtt[3])]; + if (rtt[4]) s.mdev = Number(rtt[4]); + } + const err = line.match( + /(Destination (?:Host|Net) Unreachable|Request timeout|Name or service not known|Temporary failure in name resolution|unknown host|Network is unreachable)/i, + ); + if (err && !s.errors.includes(err[1])) s.errors.push(err[1]); + } + // Partial output: statistics from the replies so far. + if (s.avg === undefined && s.replies.length) { + const times = s.replies.map((r) => r.time); + s.min = Math.min(...times); + s.max = Math.max(...times); + s.avg = times.reduce((a, b) => a + b, 0) / times.length; + } + return s; +} + +export function latencyQuality(ms: number | undefined): { text: string; color: "green" | "yellow" | "orange" | "red" } { + if (ms === undefined) return { text: "No answer", color: "red" }; + if (ms < 30) return { text: "Excellent", color: "green" }; + if (ms < 80) return { text: "Good", color: "green" }; + if (ms < 150) return { text: "Fair", color: "yellow" }; + return { text: "Slow", color: "orange" }; +} + +export const ms = ( + v: number | undefined, +) => (v === undefined ? "–" : v < 10 ? `${v.toFixed(2)} ms` : `${v.toFixed(1)} ms`); + +// --------------------------------------------------------------------------- +// Whois + +export interface Whois { + fields: { label: string; value: string }[]; + nameServers: string[]; + statuses: string[]; + raw: string; +} + +const WHOIS_FIELDS: [string, RegExp][] = [ + ["Domain", /^(?:domain name|domain)$/i], + ["Registrar", /^(?:registrar|sponsoring registrar|registrar name)$/i], + ["Registrant", /^(?:registrant organization|registrant|org|organisation|orgname|org-name|owner)$/i], + ["Created", /^(?:creation date|created|registered on|registration time|domain registration date|regdate)$/i], + ["Updated", /^(?:updated date|last updated|last-update|changed|last modified|updated)$/i], + [ + "Expires", + /^(?:registry expiry date|registrar registration expiration date|expiry date|expiration date|expires|paid-till|expire date)$/i, + ], + ["Country", /^(?:registrant country|country)$/i], + ["Network", /^(?:netname|net-name)$/i], + ["Range", /^(?:cidr|inetnum|netrange|inet6num|route)$/i], + ["ASN", /^(?:originas|origin|aut-num)$/i], + ["Abuse", /^(?:orgabuseemail|abuse-mailbox|registrar abuse contact email)$/i], + ["DNSSEC", /^dnssec$/i], +]; + +/** The useful lines of `whois` output, first value of each kind. */ +export function parseWhois(raw: string): Whois { + const fields = new Map<string, string>(); + const ns = new Set<string>(); + const statuses = new Set<string>(); + for (const line of raw.split("\n")) { + if (/^\s*[%#>]/.test(line)) continue; + const m = line.match(/^\s*([A-Za-z][\w /.-]*?)\s*:\s*(.+?)\s*$/); + if (!m) continue; + const [, key, value] = m; + if (/^(?:name server|nserver|nameservers?|name servers)$/i.test(key)) { + ns.add(value.split(/\s+/)[0].toLowerCase().replace(/\.$/, "")); + continue; + } + if (/^(?:domain status|status)$/i.test(key)) { + statuses.add(value.split(/\s+/)[0]); + continue; + } + for (const [label, re] of WHOIS_FIELDS) { + if (re.test(key) && !fields.has(label) && !/redacted|not disclosed/i.test(value)) fields.set(label, value); + } + } + return { + fields: [...fields].map(([label, value]) => ({ label, value })), + nameServers: [...ns], + statuses: [...statuses], + raw: raw.trim(), + }; +} + +/** Days until the expiry date, if there is one and it parses. */ +export function daysUntil(dateText: string | undefined, now = Date.now()): number | undefined { + if (!dateText) return undefined; + const t = Date.parse(dateText.replace(/^(\d{4})\.(\d{2})\.(\d{2})/, "$1-$2-$3")); + return Number.isNaN(t) ? undefined : Math.floor((t - now) / 86400000); +} + +/** "2026-03-01T12:00:00Z" → "1 Mar 2026"; anything else as is. */ +export function shortDate(text: string): string { + const t = Date.parse(text); + return Number.isNaN(t) + ? text + : new Date(t).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); +} + +// --------------------------------------------------------------------------- +// Public IP and geolocation (ipinfo.io) + +export interface Geo { + ip: string; + hostname?: string; + city?: string; + region?: string; + country?: string; + loc?: { lat: number; lon: number }; + org?: string; + asn?: string; + postal?: string; + timezone?: string; +} + +export function parseIpinfo(json: unknown): Geo { + const j = json as Record<string, string | undefined>; + const [lat, lon] = (j.loc ?? "").split(",").map(Number); + const org = j.org?.match(/^(AS\d+)\s+(.*)$/); + return { + ip: String(j.ip ?? ""), + hostname: j.hostname || undefined, + city: j.city || undefined, + region: j.region || undefined, + country: j.country || undefined, + loc: Number.isFinite(lat) && Number.isFinite(lon) && j.loc ? { lat, lon } : undefined, + org: org ? org[2] : j.org || undefined, + asn: org ? org[1] : undefined, + postal: j.postal || undefined, + timezone: j.timezone || undefined, + }; +} + +/** "FR" → "🇫🇷 France". */ +export function countryName(code: string | undefined): string | undefined { + if (!code || !/^[A-Z]{2}$/i.test(code)) return code; + const flag = [...code.toUpperCase()].map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65)).join(""); + let name = code.toUpperCase(); + try { + name = new Intl.DisplayNames(["en"], { type: "region" }).of(name) ?? name; + } catch { + // Keep the code. + } + return `${flag} ${name}`; +} + +export const place = (g: Geo) => + [g.city, g.region !== g.city ? g.region : undefined, g.country].filter(Boolean).join(", "); + +// --------------------------------------------------------------------------- +// Local interfaces (`ip -j -s addr`, `ip -j route`) + +export interface Address { + family: "inet" | "inet6"; + address: string; + prefix: number; + scope: string; +} + +export interface Interface { + name: string; + state: string; + up: boolean; + mac?: string; + mtu: number; + kind: "loopback" | "ethernet" | "wifi" | "vpn" | "bridge" | "virtual" | "other"; + addresses: Address[]; + rx?: number; + tx?: number; + gateway?: string; + isDefault?: boolean; +} + +export function interfaceKind(name: string, linkType: string | undefined): Interface["kind"] { + if (linkType === "loopback" || name === "lo") return "loopback"; + if (/^(wl|wlan|wifi)/.test(name)) return "wifi"; + if (/^(tun|tap|wg|tailscale|ppp|nordlynx|proton|mullvad|vpn|zt)/.test(name) || linkType === "none") return "vpn"; + if (/^(br|virbr|docker|cni|podman|lxc|lxd)/.test(name)) return "bridge"; + if (/^(veth|vnet|ifb|dummy|vxlan|bond)/.test(name)) return "virtual"; + if (/^(en|eth|em|eno|ens|enp|usb)/.test(name)) return "ethernet"; + return "other"; +} + +export function parseInterfaces(addrJson: string, routeJson = "[]"): Interface[] { + const links = JSON.parse(addrJson || "[]") as { + ifname: string; + flags?: string[]; + mtu: number; + operstate?: string; + link_type?: string; + address?: string; + addr_info?: { family: string; local: string; prefixlen: number; scope: string }[]; + stats64?: { rx?: { bytes: number }; tx?: { bytes: number } }; + }[]; + const routes = JSON.parse(routeJson || "[]") as { dst: string; gateway?: string; dev?: string; metric?: number }[]; + const defaults = routes.filter((r) => r.dst === "default").sort((a, b) => (a.metric ?? 0) - (b.metric ?? 0)); + return links.map((l): Interface => { + const def = defaults.find((r) => r.dev === l.ifname); + const up = (l.flags ?? []).includes("UP") && (l.flags ?? []).includes("LOWER_UP"); + return { + name: l.ifname, + state: (l.operstate ?? "UNKNOWN").toLowerCase(), + up, + mac: l.address && l.address !== "00:00:00:00:00:00" && l.link_type === "ether" ? l.address : undefined, + mtu: l.mtu, + kind: interfaceKind(l.ifname, l.link_type), + addresses: (l.addr_info ?? []).filter((a) => a.family === "inet" || a.family === "inet6").map((a) => ({ + family: a.family as Address["family"], + address: a.local, + prefix: a.prefixlen, + scope: a.scope, + })), + rx: l.stats64?.rx?.bytes, + tx: l.stats64?.tx?.bytes, + gateway: def?.gateway, + isDefault: def ? def === defaults[0] : undefined, + }; + }); +} + +/** Interfaces worth showing first: the default route, then up ones with addresses, loopback last. */ +export function rankInterfaces(list: Interface[]): Interface[] { + const score = (i: Interface) => + i.isDefault ? 0 : i.kind === "loopback" ? 4 : i.up && i.addresses.length ? 1 : i.up ? 2 : 3; + return [...list].sort((a, b) => score(a) - score(b) || a.name.localeCompare(b.name)); +} + +// --------------------------------------------------------------------------- +// Speed test + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["kB", "MB", "GB", "TB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; +} + +/** Bits per second, readable: "94.3 Mbit/s". */ +export function formatSpeed(bps: number): string { + if (bps >= 1e9) return `${(bps / 1e9).toFixed(2)} Gbit/s`; + if (bps >= 1e6) return `${(bps / 1e6).toFixed(bps >= 1e8 ? 0 : 1)} Mbit/s`; + if (bps >= 1e3) return `${(bps / 1e3).toFixed(0)} kbit/s`; + return `${Math.round(bps)} bit/s`; +} + +/** The 90th percentile of per-request speeds, as Cloudflare's own test reports. */ +export function percentile(values: number[], p: number): number { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const idx = (sorted.length - 1) * p; + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo); +} + +export const median = (values: number[]) => percentile(values, 0.5); + +/** Mean absolute difference between consecutive latencies. */ +export function jitter(latencies: number[]): number { + if (latencies.length < 2) return 0; + let sum = 0; + for (let i = 1; i < latencies.length; i++) sum += Math.abs(latencies[i] - latencies[i - 1]); + return sum / (latencies.length - 1); +} + +/** + * The server's own processing time from its `Server-Timing: cfRequestDuration;dur=12.3` + * header, subtracted from the measured time to leave the network part. + */ +export function serverTime(header: string | null): number { + const m = header?.match(/dur=([\d.]+)/); + return m ? Number(m[1]) : 0; +} + +/** Download sizes, growing, cut when a step takes long enough to be meaningful. */ +export const DOWNLOAD_STEPS = [100_000, 1_000_000, 10_000_000, 25_000_000, 100_000_000]; +export const UPLOAD_STEPS = [100_000, 1_000_000, 10_000_000, 25_000_000]; + +export function speedVerdict(downBps: number): string { + const mbps = downBps / 1e6; + if (mbps >= 300) return "Fast enough for anything, several 4K streams at once"; + if (mbps >= 50) return "Great for 4K streaming and large downloads"; + if (mbps >= 15) return "Good for HD video calls and streaming"; + if (mbps >= 5) return "Fine for browsing and HD streaming"; + return "Slow: browsing works, video may stutter"; +} + +// --------------------------------------------------------------------------- +// 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<string, Record<Family, string>> = { + whois: { arch: "whois", debian: "whois", fedora: "whois", suse: "whois" }, + ping: { arch: "iputils", debian: "iputils-ping", fedora: "iputils", suse: "iputils" }, + dig: { arch: "bind", debian: "dnsutils", fedora: "bind-utils", suse: "bind-utils" }, + ip: { arch: "iproute2", debian: "iproute2", fedora: "iproute", suse: "iproute2" }, +}; + +const MANAGERS: Record<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +const FAMILY_NAMES: Record<Family, string> = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +export function installCommand(program: string, family: Family): string { + return `${MANAGERS[family]} ${PACKAGES[program]?.[family] ?? program}`; +} + +export function installMarkdown(what: string, program: 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(program, f)}\``).join("\n\n") + }`; +} diff --git a/extensions/network-tools/lib_test.ts b/extensions/network-tools/lib_test.ts new file mode 100644 index 0000000..a7df13b --- /dev/null +++ b/extensions/network-tools/lib_test.ts @@ -0,0 +1,307 @@ +import { assert, assertEquals } from "@std/assert"; +import { + addressScope, + cleanHost, + countryName, + daysUntil, + expandIPv6, + formatSpeed, + formatTtl, + installMarkdown, + isHostname, + isIPv4, + isIPv6, + jitter, + median, + osFamily, + parseDig, + parseDoh, + parseIntent, + parseInterfaces, + parseIpinfo, + parsePing, + parseWhois, + percentile, + place, + rankInterfaces, + reverseName, + serverTime, + shortDate, + unquoteTxt, +} from "./lib.ts"; + +Deno.test("addresses and hosts", () => { + assert(isIPv4("192.168.1.10") && !isIPv4("256.1.1.1") && !isIPv4("1.2.3")); + for (const ok of ["::1", "2606:4700:4700::1111", "fe80::1ff:fe23:4567:890a", "::ffff:192.0.2.1", "1:2:3:4:5:6:7:8"]) { + assert(isIPv6(ok), ok); + } + for (const bad of ["1::2::3", "12345::", "1:2:3:4:5:6:7:8:9", "example.com", ":"]) assert(!isIPv6(bad), bad); + assert(isHostname("www.example.co.uk") && isHostname("localhost") && isHostname("_dmarc.example.com")); + assert(!isHostname("-rf.example.com") && !isHostname("example") && !isHostname("1.2.3")); + assertEquals(cleanHost("https://www.Example.com/path?q=1"), "www.example.com"); + assertEquals(cleanHost("example.com:8080"), "example.com"); + assertEquals(cleanHost("[2606:4700::1111]"), "2606:4700::1111"); + assertEquals(cleanHost("--help"), undefined, "never an option"); + assertEquals(cleanHost("a b"), undefined); + assertEquals(addressScope("10.0.0.4"), "Private"); + assertEquals(addressScope("172.20.1.1"), "Private"); + assertEquals(addressScope("100.101.1.1"), "Carrier-grade NAT"); + assertEquals(addressScope("8.8.8.8"), undefined); + assertEquals(addressScope("fe80::1"), "Link-local"); + assertEquals(expandIPv6("2001:db8::1"), "2001:0db8:0000:0000:0000:0000:0000:0001"); + assertEquals(reverseName("1.2.3.4"), "4.3.2.1.in-addr.arpa"); + assert( + reverseName("2001:db8::1").startsWith("1.0.0.0.0.0.0.0.") && + reverseName("2001:db8::1").endsWith("8.b.d.0.1.0.0.2.ip6.arpa"), + ); +}); + +Deno.test("intents", () => { + assertEquals(parseIntent(""), { kind: "home" }); + assertEquals(parseIntent("ip"), { kind: "ip" }); + assertEquals(parseIntent("speedtest"), { kind: "speed" }); + assertEquals(parseIntent("interfaces"), { kind: "interfaces" }); + assertEquals(parseIntent("dns example.com"), { + kind: "dns", + host: "example.com", + types: ["A", "AAAA", "CNAME", "MX", "NS", "TXT"], + }); + assertEquals(parseIntent("dig example.com mx txt"), { kind: "dns", host: "example.com", types: ["MX", "TXT"] }); + assertEquals(parseIntent("dns 1.1.1.1"), { kind: "dns", host: "1.1.1.1", types: ["PTR"] }); + assertEquals(parseIntent("ping 1.1.1.1"), { kind: "ping", host: "1.1.1.1" }); + assertEquals(parseIntent("whois https://github.com/x"), { kind: "whois", host: "github.com" }); + assertEquals(parseIntent("ping -f"), { kind: "invalid", tool: "ping", text: "-f" }); + assertEquals(parseIntent("example.org"), { kind: "host", host: "example.org" }); + assertEquals(parseIntent("hello there"), { kind: "home" }); +}); + +Deno.test("DNS over HTTPS answers", () => { + // cloudflare-dns.com/dns-query?name=www.github.com&type=A + const a = parseDoh("A", { + Status: 0, + TC: false, + RD: true, + RA: true, + AD: false, + CD: false, + Question: [{ name: "www.github.com", type: 1 }], + Answer: [ + { name: "www.github.com", type: 5, TTL: 3600, data: "github.com." }, + { name: "github.com", type: 1, TTL: 60, data: "140.82.121.4" }, + ], + }); + assertEquals(a.records, [{ name: "github.com", type: "A", ttl: 60, data: "140.82.121.4" }]); + const mx = parseDoh("MX", { + Status: 0, + Answer: [ + { name: "gmail.com.", type: 15, TTL: 3600, data: "20 alt2.gmail-smtp-in.l.google.com." }, + { name: "gmail.com.", type: 15, TTL: 3600, data: "5 gmail-smtp-in.l.google.com." }, + ], + }); + assertEquals(mx.records.map((r) => [r.priority, r.data]), [[5, "gmail-smtp-in.l.google.com"], [ + 20, + "alt2.gmail-smtp-in.l.google.com", + ]]); + const txt = parseDoh("TXT", { + Status: 0, + Answer: [{ name: "example.com", type: 16, TTL: 300, data: '"v=spf1 -all"' }], + }); + assertEquals(txt.records[0].data, "v=spf1 -all"); + assertEquals(parseDoh("A", { Status: 3, Authority: [] }).status, "NXDOMAIN"); + assertEquals(unquoteTxt('"v=DKIM1; k=rsa; " "p=MIGf"'), "v=DKIM1; k=rsa; p=MIGf"); +}); + +Deno.test("dig output", () => { + const out = `;; Got answer: +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 4242 +example.com. 3600 IN MX 10 mail.example.com. +example.com. 3600 IN MX 0 . +`; + const mx = parseDig("MX", out); + assertEquals(mx.records.map((r) => [r.priority, r.data]), [[0, ""], [10, "mail.example.com"]]); + assertEquals(parseDig("A", ";; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 1\n").status, "NXDOMAIN"); + assertEquals(formatTtl(45), "45 s"); + assertEquals(formatTtl(7200), "2 h"); +}); + +Deno.test("ping output", () => { + const out = `PING one.one.one.one (1.1.1.1) 56(84) bytes of data. +64 bytes from one.one.one.one (1.1.1.1): icmp_seq=1 ttl=57 time=11.9 ms +64 bytes from one.one.one.one (1.1.1.1): icmp_seq=2 ttl=57 time=12.6 ms +64 bytes from one.one.one.one (1.1.1.1): icmp_seq=4 ttl=57 time=12.1 ms + +--- one.one.one.one ping statistics --- +4 packets transmitted, 3 received, 25% packet loss, time 3005ms +rtt min/avg/max/mdev = 11.912/12.204/12.611/0.298 ms +`; + const s = parsePing("one.one.one.one", out); + assertEquals(s.address, "1.1.1.1"); + assertEquals(s.replies.map((r) => r.seq), [1, 2, 4]); + assertEquals([s.transmitted, s.received, s.loss], [4, 3, 25]); + assertEquals([s.min, s.avg, s.max, s.mdev], [11.912, 12.204, 12.611, 0.298]); + + const partial = parsePing("x", out.split("\n").slice(0, 3).join("\n")); + assertEquals([partial.transmitted, partial.min, partial.max], [undefined, 11.9, 12.6]); + assertEquals(partial.avg, 12.25); + + const busybox = parsePing( + "8.8.8.8", + `PING 8.8.8.8 (8.8.8.8): 56 data bytes +64 bytes from 8.8.8.8: seq=0 ttl=117 time=9.876 ms +--- 8.8.8.8 ping statistics --- +1 packets transmitted, 1 packets received, 0% packet loss +round-trip min/avg/max = 9.876/9.876/9.876 ms`, + ); + assertEquals([busybox.loss, busybox.avg], [0, 9.876]); + + const down = parsePing( + "10.9.9.9", + `PING 10.9.9.9 (10.9.9.9) 56(84) bytes of data. +From 10.0.0.1 icmp_seq=1 Destination Host Unreachable + +--- 10.9.9.9 ping statistics --- +2 packets transmitted, 0 received, +1 errors, 100% packet loss, time 1001ms`, + ); + assertEquals([down.loss, down.avg, down.errors], [100, undefined, ["Destination Host Unreachable"]]); + const unknown = parsePing("nope.invalid", "ping: nope.invalid: Name or service not known"); + assertEquals(unknown.errors, ["Name or service not known"]); +}); + +Deno.test("whois output", () => { + const com = ` Domain Name: EXAMPLE.COM + Registry Domain ID: 2336799_DOMAIN_COM-VRSN + Registrar WHOIS Server: whois.iana.org + Updated Date: 2025-08-14T07:01:39Z + Creation Date: 1995-08-14T04:00:00Z + Registry Expiry Date: 2026-08-13T04:00:00Z + Registrar: RESERVED-Internet Assigned Numbers Authority + Registrar IANA ID: 376 + Domain Status: clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited + Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited + Name Server: A.IANA-SERVERS.NET + Name Server: B.IANA-SERVERS.NET + DNSSEC: signedDelegation +>>> Last update of whois database: 2026-10-28T09:12:08Z <<< +Registrant Organization: REDACTED FOR PRIVACY +`; + const w = parseWhois(com); + assertEquals(w.fields, [ + { label: "Domain", value: "EXAMPLE.COM" }, + { label: "Updated", value: "2025-08-14T07:01:39Z" }, + { label: "Created", value: "1995-08-14T04:00:00Z" }, + { label: "Expires", value: "2026-08-13T04:00:00Z" }, + { label: "Registrar", value: "RESERVED-Internet Assigned Numbers Authority" }, + { label: "DNSSEC", value: "signedDelegation" }, + ]); + assertEquals(w.nameServers, ["a.iana-servers.net", "b.iana-servers.net"]); + assertEquals(w.statuses, ["clientDeleteProhibited", "clientTransferProhibited"]); + assertEquals(daysUntil("2026-11-07T00:00:00Z", Date.parse("2026-10-28T00:00:00Z")), 10); + assertEquals(shortDate("1995-08-14T04:00:00Z"), "14 Aug 1995"); + + const ripe = parseWhois(`% This is the RIPE Database query service. +inetnum: 193.0.0.0 - 193.0.7.255 +netname: RIPE-NCC +country: NL +org: ORG-RIEN1-RIPE +abuse-mailbox: abuse@ripe.net +origin: AS3333 +`); + assertEquals(ripe.fields.map((f) => f.label), ["Range", "Network", "Country", "Registrant", "Abuse", "ASN"]); + assertEquals(parseWhois('No match for "NOPE-NOPE.COM".').fields, []); +}); + +Deno.test("ipinfo geolocation", () => { + const g = parseIpinfo({ + ip: "8.8.8.8", + hostname: "dns.google", + city: "Mountain View", + region: "California", + country: "US", + loc: "37.4056,-122.0775", + org: "AS15169 Google LLC", + postal: "94043", + timezone: "America/Los_Angeles", + anycast: true, + }); + assertEquals(g.loc, { lat: 37.4056, lon: -122.0775 }); + assertEquals([g.org, g.asn], ["Google LLC", "AS15169"]); + assertEquals(place(g), "Mountain View, California, US"); + assertEquals(parseIpinfo({ ip: "10.0.0.1", bogon: true }).loc, undefined); + assertEquals(countryName("FR"), "🇫🇷 France"); +}); + +Deno.test("interfaces from ip -j", () => { + const addr = JSON.stringify([ + { + ifindex: 1, + ifname: "lo", + flags: ["LOOPBACK", "UP", "LOWER_UP"], + mtu: 65536, + operstate: "UNKNOWN", + link_type: "loopback", + address: "00:00:00:00:00:00", + addr_info: [{ family: "inet", local: "127.0.0.1", prefixlen: 8, scope: "host" }], + }, + { + ifindex: 3, + ifname: "wlp2s0", + flags: ["BROADCAST", "MULTICAST", "UP", "LOWER_UP"], + mtu: 1500, + operstate: "UP", + link_type: "ether", + address: "a4:c3:f0:11:22:33", + addr_info: [ + { family: "inet", local: "192.168.1.42", prefixlen: 24, scope: "global" }, + { family: "inet6", local: "fe80::a6c3:f0ff:fe11:2233", prefixlen: 64, scope: "link" }, + ], + stats64: { rx: { bytes: 1234567890 }, tx: { bytes: 98765432 } }, + }, + { + ifindex: 4, + ifname: "docker0", + flags: ["NO-CARRIER", "BROADCAST", "MULTICAST", "UP"], + mtu: 1500, + operstate: "DOWN", + link_type: "ether", + address: "02:42:ac:11:00:01", + addr_info: [{ family: "inet", local: "172.17.0.1", prefixlen: 16, scope: "global" }], + }, + { ifindex: 5, ifname: "wg0", flags: ["POINTOPOINT", "NOARP", "UP", "LOWER_UP"], mtu: 1420, link_type: "none" }, + ]); + const route = JSON.stringify([ + { dst: "default", gateway: "192.168.1.1", dev: "wlp2s0", protocol: "dhcp", metric: 600 }, + { dst: "192.168.1.0/24", dev: "wlp2s0" }, + ]); + const list = parseInterfaces(addr, route); + const wifi = list.find((i) => i.name === "wlp2s0")!; + assertEquals([wifi.kind, wifi.up, wifi.gateway, wifi.isDefault, wifi.rx], [ + "wifi", + true, + "192.168.1.1", + true, + 1234567890, + ]); + assertEquals(list.find((i) => i.name === "docker0")!.up, false); + assertEquals(list.find((i) => i.name === "wg0")!.kind, "vpn"); + assertEquals(list.find((i) => i.name === "lo")!.mac, undefined); + assertEquals(rankInterfaces(list).map((i) => i.name), ["wlp2s0", "wg0", "docker0", "lo"]); +}); + +Deno.test("speed test maths", () => { + assertEquals(formatSpeed(94_300_000), "94.3 Mbit/s"); + assertEquals(formatSpeed(512_000_000), "512 Mbit/s"); + assertEquals(formatSpeed(1_234_000_000), "1.23 Gbit/s"); + assertEquals(median([5, 1, 3]), 3); + assertEquals(percentile([10, 20, 30, 40, 50], 0.9), 46); + assertEquals(jitter([10, 12, 11, 15]), (2 + 1 + 4) / 3); + assertEquals(serverTime("cfRequestDuration;dur=12.5"), 12.5); + assertEquals(serverTime(null), 0); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian\n"), "debian"); + assertEquals(osFamily('ID="fedora"\n'), "fedora"); + assert(installMarkdown("Whois", "whois", "arch").includes("sudo pacman -S whois")); + assert(installMarkdown("Ping", "ping", "debian").includes("sudo apt install iputils-ping")); + assert(installMarkdown("DNS", "dig", undefined).includes("sudo dnf install bind-utils")); +}); diff --git a/extensions/network-tools/system.ts b/extensions/network-tools/system.ts new file mode 100644 index 0000000..267e012 --- /dev/null +++ b/extensions/network-tools/system.ts @@ -0,0 +1,278 @@ +// Everything that touches the network or runs a program. + +import { exec } from "@backslash/api"; +import { + type DnsAnswer, + DOWNLOAD_STEPS, + type Family, + type Geo, + type Interface, + isIP, + osFamily, + parseDig, + parseDoh, + parseInterfaces, + parseIpinfo, + parsePing, + parseWhois, + type PingStats, + type RecordType, + reverseName, + serverTime, + UPLOAD_STEPS, + type Whois, +} from "./lib.ts"; + +export class Missing extends Error { + constructor(public program: string) { + super(`${program} is not installed`); + } +} + +const notFound = (e: unknown) => + e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable; + +/** Runs a program; Missing when it isn't installed. */ +async function run(program: string, args: string[], signal?: AbortSignal): Promise<string> { + try { + return await exec(program, args, { signal }); + } catch (e) { + if (notFound(e)) throw new Missing(program); + throw e; + } +} + +export async function family(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function json(url: string, signal?: AbortSignal, headers: Record<string, string> = {}): Promise<unknown> { + const res = await fetch(url, { signal, headers: { Accept: "application/json", ...headers } }); + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`${new URL(url).hostname} answered ${res.status}`); + } + return await res.json(); +} + +// --------------------------------------------------------------------------- +// Public IP + +/** Public IPv4 and IPv6 (each undefined when there is none, or it can't be reached). */ +export async function publicIps(signal?: AbortSignal): Promise<{ v4?: string; v6?: string; error?: string }> { + const get = (host: string) => + json( + `https://${host}?format=json`, + AbortSignal.any([signal ?? new AbortController().signal, AbortSignal.timeout(6000)]), + ) + .then((j) => String((j as { ip: string }).ip)) + .catch((e: Error) => e); + const [v4, v6] = await Promise.all([get("api.ipify.org"), get("api6.ipify.org")]); + return { + v4: typeof v4 === "string" ? v4 : undefined, + v6: typeof v6 === "string" && v6.includes(":") ? v6 : undefined, + error: typeof v4 !== "string" && typeof v6 !== "string" ? (v4 as Error).message : undefined, + }; +} + +/** Where an address is, from ipinfo.io (50 000 lookups a month without a token). */ +export async function geolocate(ip: string, token?: string, signal?: AbortSignal): Promise<Geo> { + const url = `https://ipinfo.io/${encodeURIComponent(ip)}/json`; + return parseIpinfo(await json(url, signal, token ? { Authorization: `Bearer ${token}` } : {})); +} + +// --------------------------------------------------------------------------- +// Interfaces + +export async function interfaces(): Promise<Interface[]> { + const [addr, route, route6] = await Promise.all([ + run("ip", ["-j", "-s", "addr"]), + run("ip", ["-j", "route"]).catch(() => "[]"), + run("ip", ["-j", "-6", "route"]).catch(() => "[]"), + ]); + const routes = [...JSON.parse(route || "[]"), ...JSON.parse(route6 || "[]")]; + return parseInterfaces(addr, JSON.stringify(routes)); +} + +// --------------------------------------------------------------------------- +// DNS + +/** + * One record type, over DNS-over-HTTPS (Cloudflare), or with dig through the + * system resolver when `resolver` is "system" or Cloudflare can't be reached. + */ +export async function lookup( + host: string, + type: RecordType, + resolver: string, + signal?: AbortSignal, +): Promise<DnsAnswer & { via: string }> { + const name = type === "PTR" && isIP(host) ? reverseName(host) : host; + if (resolver !== "system") { + try { + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`; + return { + ...parseDoh(type, await json(url, signal, { Accept: "application/dns-json" })), + via: "Cloudflare (DoH)", + }; + } catch (e) { + if (signal?.aborted) throw e; + // Fall back on the system resolver. + } + } + const out = await run("dig", ["+noall", "+answer", "+comments", "+time=3", "+tries=1", "--", name, type], signal); + return { ...parseDig(type, out), via: "System resolver (dig)" }; +} + +// --------------------------------------------------------------------------- +// Ping + +/** Pings `count` times, yielding the statistics after each line. */ +export async function* ping(host: string, count: number, signal: AbortSignal): AsyncGenerator<PingStats> { + let child: Deno.ChildProcess; + try { + child = new Deno.Command("ping", { + args: ["-c", String(count), "-W", "3", "--", host], + stdin: "null", + stdout: "piped", + stderr: "piped", + signal, + }).spawn(); + } catch (e) { + if (notFound(e)) throw new Missing("ping"); + throw e; + } + const stderr = new Response(child.stderr).text(); + let out = ""; + const decoder = new TextDecoder(); + for await (const chunk of child.stdout) { + out += decoder.decode(chunk, { stream: true }); + yield parsePing(host, out); + } + const status = await child.status; + const err = (await stderr).trim(); + const stats = parsePing(host, `${out}\n${err}`); + if (!status.success && !stats.transmitted && err) { + stats.errors = stats.errors.length ? stats.errors : [err.replace(/^ping:\s*/, "")]; + } + yield stats; +} + +// --------------------------------------------------------------------------- +// Whois + +export async function whois(host: string, signal?: AbortSignal): Promise<Whois> { + // Domains are looked up without "www.": registries know the registered name. + const query = isIP(host) ? host : host.replace(/^www\./, ""); + let out: string; + try { + out = await run("whois", ["--", query], signal); + } catch (e) { + // whois exits with 1 for "no match" while printing the answer. + if (e instanceof Missing) throw e; + const m = String((e as Error).message).match(/exited with \d+: ([\s\S]*)/); + if (!m) throw e; + out = m[1]; + } + return parseWhois(out); +} + +// --------------------------------------------------------------------------- +// Speed test (speed.cloudflare.com) + +export interface SpeedProgress { + phase: "latency" | "download" | "upload" | "done"; + /** Unloaded latencies, ms. */ + latencies: number[]; + /** Speed of each finished request, bit/s. */ + down: number[]; + up: number[]; + /** Speed of the request in progress. */ + current?: number; + bytesDown: number; + bytesUp: number; + /** 0 to 1, for the whole test. */ + progress: number; + error?: string; +} + +const SPEED = "https://speed.cloudflare.com"; +const MAX_STEP_MS = 4000; +const PHASE_BUDGET_MS = 12000; + +export async function* speedTest(signal: AbortSignal): AsyncGenerator<SpeedProgress> { + const p: SpeedProgress = { phase: "latency", latencies: [], down: [], up: [], bytesDown: 0, bytesUp: 0, progress: 0 }; + const snapshot = () => ({ ...p, latencies: [...p.latencies], down: [...p.down], up: [...p.up] }); + yield snapshot(); + + // Latency: time to the first byte of empty downloads, minus the server's own time. + for (let i = 0; i < 10; i++) { + const start = performance.now(); + const res = await fetch(`${SPEED}/__down?bytes=0`, { signal, cache: "no-store" }); + const first = performance.now(); + await res.arrayBuffer(); + p.latencies.push(Math.max(0.1, first - start - serverTime(res.headers.get("server-timing")))); + p.progress = 0.1 * ((i + 1) / 10); + if (i % 3 === 2) yield snapshot(); + } + + p.phase = "download"; + let phaseStart = performance.now(); + for (const [n, bytes] of DOWNLOAD_STEPS.entries()) { + const res = await fetch(`${SPEED}/__down?bytes=${bytes}`, { signal, cache: "no-store" }); + if (!res.ok || !res.body) throw new Error(`speed.cloudflare.com answered ${res.status}`); + const reader = res.body.getReader(); + const start = performance.now(); + let got = 0; + let lastYield = start; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + got += value.byteLength; + p.bytesDown += value.byteLength; + const now = performance.now(); + if (now - lastYield > 300) { + lastYield = now; + p.current = (got * 8) / ((now - start) / 1000); + p.progress = 0.1 + 0.5 * Math.min(1, (now - phaseStart) / PHASE_BUDGET_MS); + yield snapshot(); + } + } + const took = Math.max(1, performance.now() - start); + p.down.push((got * 8) / (took / 1000)); + p.current = undefined; + const spent = performance.now() - phaseStart; + p.progress = 0.1 + 0.5 * Math.min(1, spent / PHASE_BUDGET_MS); + yield snapshot(); + if (took > MAX_STEP_MS || spent > PHASE_BUDGET_MS || n === DOWNLOAD_STEPS.length - 1) break; + } + + p.phase = "upload"; + p.progress = 0.6; + yield snapshot(); + phaseStart = performance.now(); + const latency = p.latencies.length ? [...p.latencies].sort((a, b) => a - b)[p.latencies.length >> 1] : 0; + for (const [n, bytes] of UPLOAD_STEPS.entries()) { + const body = new Uint8Array(bytes); + const start = performance.now(); + const res = await fetch(`${SPEED}/__up`, { + method: "POST", + body, + signal, + headers: { "Content-Type": "application/octet-stream" }, + }); + await res.arrayBuffer(); + if (!res.ok) throw new Error(`speed.cloudflare.com answered ${res.status}`); + const took = Math.max(1, performance.now() - start - latency - serverTime(res.headers.get("server-timing"))); + p.bytesUp += bytes; + p.up.push((bytes * 8) / (took / 1000)); + const spent = performance.now() - phaseStart; + p.progress = 0.6 + 0.4 * Math.min(1, spent / PHASE_BUDGET_MS); + yield snapshot(); + if (took > MAX_STEP_MS || spent > PHASE_BUDGET_MS || n === UPLOAD_STEPS.length - 1) break; + } + p.phase = "done"; + p.progress = 1; + yield snapshot(); +} diff --git a/extensions/network-tools/ui.ts b/extensions/network-tools/ui.ts new file mode 100644 index 0000000..9df89c5 --- /dev/null +++ b/extensions/network-tools/ui.ts @@ -0,0 +1,654 @@ +// Items and views shared by the commands. + +import { + action, + type Color, + type Context, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + storage, + updateCommandMetadata, +} from "@backslash/api"; +import { + addressScope, + countryName, + daysUntil, + defaultTypes, + type DnsAnswer, + formatBytes, + formatSpeed, + formatTtl, + type Geo, + installMarkdown, + type Interface, + isIP, + isIPv4, + jitter, + latencyQuality, + median, + ms, + percentile, + place, + rankInterfaces, + type RecordType, + shortDate, + speedVerdict, +} from "./lib.ts"; +import { + family, + geolocate, + interfaces, + lookup, + Missing, + ping, + publicIps, + type SpeedProgress, + speedTest, + whois, +} from "./system.ts"; + +export interface Prefs { + resolver?: string; + ipinfoToken?: string; + pingCount?: number; +} + +export const prefs = (ctx: Context) => ctx.preferences as Prefs; + +/** An item explaining what to install, for the distribution in /etc/os-release. */ +export async function missingItem(program: string, why: string, section?: string): Promise<Item> { + const fam = await family(); + return { + id: `missing:${program}`, + title: `${program} is not installed`, + subtitle: why, + icon: { source: Icons.alert, tint: "orange" }, + section, + detail: { markdown: installMarkdown(`${why} needs **${program}**. Install it, then try again.`, program, fam) }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; +} + +const errorItem = (title: string, e: unknown, section?: string): Item => ({ + id: `error:${title}`, + title, + subtitle: (e as Error).message, + icon: { source: Icons.alert, tint: "red" }, + section, + detail: { markdown: `**${title}**\n\n\`${(e as Error).message}\`` }, + actions: [action("Try Again", () => refresh(), { icon: Icons.refresh })], +}); + +// --------------------------------------------------------------------------- +// Public IP + +function geoMetadata(g: Geo | undefined): Metadata[] { + if (!g) return [meta.text("Location", "Unknown", { icon: Icons.globe })]; + return [ + ...(place(g) ? [meta.text("Location", place(g), { icon: Icons.pin })] : []), + ...(g.country ? [meta.text("Country", countryName(g.country) ?? g.country, { icon: Icons.flag })] : []), + ...(g.org ? [meta.text("Provider", g.org, { icon: Icons.server })] : []), + ...(g.asn ? [meta.link("ASN", g.asn, `https://bgp.he.net/${g.asn}`)] : []), + ...(g.hostname ? [meta.text("Reverse DNS", g.hostname, { icon: Icons.tag })] : []), + ...(g.timezone ? [meta.text("Time zone", g.timezone, { icon: Icons.clock })] : []), + ...(g.postal ? [meta.text("Postal code", g.postal)] : []), + ...(g.loc + ? [meta.link( + "Coordinates", + `${g.loc.lat.toFixed(4)}, ${g.loc.lon.toFixed(4)}`, + `https://www.openstreetmap.org/?mlat=${g.loc.lat}&mlon=${g.loc.lon}#map=10/${g.loc.lat}/${g.loc.lon}`, + )] + : []), + ]; +} + +const geoText = (ip: string, g?: Geo) => [ip, g && place(g), g?.org, g?.asn].filter(Boolean).join(" · "); + +/** An address and where it is. `label` is "IPv4", "IPv6" or the host it was looked up for. */ +export function ipItem(ip: string, label: string, g: Geo | undefined, section: string, geoError?: string): Item { + return { + id: `ip:${ip}`, + title: ip, + subtitle: g ? [place(g), g.org].filter(Boolean).join(" · ") : geoError ? "Location unavailable" : undefined, + icon: { source: Icons.globe, tint: isIPv4(ip) ? "blue" : "purple" }, + section, + accessories: [{ tag: label }], + detail: { + markdown: geoError ? `Geolocation failed: \`${geoError}\`` : undefined, + metadata: [ + meta.text("Address", ip, { icon: Icons.globe }), + meta.text("Family", isIPv4(ip) ? "IPv4" : "IPv6"), + meta.separator(), + ...geoMetadata(g), + ], + }, + actions: [ + copy(ip, "Copy IP Address"), + copy(geoText(ip, g), "Copy with Location", { section: "Copy" }), + open(`https://ipinfo.io/${ip}`, "Open on ipinfo.io", { icon: Icons.external, section: "More" }), + ], + }; +} + +export async function publicIpItems(ctx: Context, section = "Public IP"): Promise<Item[]> { + const ips = await publicIps(ctx.signal); + if (!ips.v4 && !ips.v6) return [errorItem("No public IP: offline?", new Error(ips.error ?? "No answer"), section)]; + const token = prefs(ctx).ipinfoToken || undefined; + const geo = async (ip?: string) => ip ? await geolocate(ip, token, ctx.signal).catch((e: Error) => e) : undefined; + const [g4, g6] = await Promise.all([geo(ips.v4), geo(ips.v6)]); + const items: Item[] = []; + const add = (ip: string | undefined, label: string, g: Geo | Error | undefined) => { + if (ip) { + items.push( + ipItem(ip, label, g instanceof Error ? undefined : g, section, g instanceof Error ? g.message : undefined), + ); + } + }; + add(ips.v4, "IPv4", g4); + add(ips.v6, "IPv6", g6); + if (!ips.v6) { + items.push({ + id: "no-v6", + title: "No IPv6", + subtitle: "Your connection only reaches the internet over IPv4", + icon: { source: Icons.globe, tint: "gray" }, + section, + }); + } + const main = ips.v4 ?? ips.v6!; + const g = ips.v4 ? g4 : g6; + updateCommandMetadata({ subtitle: g instanceof Error || !g ? main : `${main} · ${place(g)}` }).catch(() => {}); + return items; +} + +// --------------------------------------------------------------------------- +// Interfaces + +const KIND: Record<Interface["kind"], { icon: string; tint: Color; name: string }> = { + ethernet: { icon: Icons.plug, tint: "blue", name: "Ethernet" }, + wifi: { icon: Icons.wifi, tint: "blue", name: "Wi-Fi" }, + vpn: { icon: Icons.vpn, tint: "purple", name: "VPN / tunnel" }, + bridge: { icon: Icons.layers, tint: "teal", name: "Bridge" }, + virtual: { icon: Icons.box, tint: "gray", name: "Virtual" }, + loopback: { icon: Icons.cycle, tint: "gray", name: "Loopback" }, + other: { icon: Icons.server, tint: "gray", name: "Interface" }, +}; + +export function interfaceItem(i: Interface, section?: string): Item { + const k = KIND[i.kind]; + const v4 = i.addresses.filter((a) => a.family === "inet"); + const v6 = i.addresses.filter((a) => a.family === "inet6"); + const main = v4[0] ?? v6.find((a) => a.scope === "global") ?? v6[0]; + return { + id: `if:${i.name}`, + title: i.name, + subtitle: main ? `${main.address}/${main.prefix}` : i.up ? "No address" : "Down", + icon: { source: k.icon, tint: i.up ? k.tint : "gray" }, + section: section ?? (i.up ? "Up" : "Down"), + keywords: [...i.addresses.map((a) => a.address), k.name, i.mac ?? ""], + accessories: [ + ...(i.isDefault ? [{ tag: "Default route", color: "green" as const }] : []), + { text: k.name }, + ], + detail: { + stats: i.rx !== undefined + ? [{ label: "Received", value: formatBytes(i.rx) }, { label: "Sent", value: formatBytes(i.tx ?? 0) }] + : [], + metadata: [ + meta.text("State", i.up ? "Up" : i.state, { + icon: i.up ? Icons.check : Icons.cross, + color: i.up ? "green" : "gray", + }), + meta.text("Type", k.name, { icon: k.icon }), + ...v4.map((a) => + meta.text("IPv4", `${a.address}/${a.prefix}${addressScope(a.address) ? ` · ${addressScope(a.address)}` : ""}`) + ), + ...v6.map((a) => meta.text("IPv6", `${a.address}/${a.prefix} · ${a.scope}`)), + ...(i.gateway ? [meta.text("Gateway", i.gateway, { icon: Icons.arrowRight })] : []), + ...(i.mac ? [meta.text("MAC", i.mac, { icon: Icons.tag })] : []), + meta.text("MTU", i.mtu), + ], + }, + actions: [ + ...(main ? [copy(main.address, "Copy Address")] : []), + ...i.addresses.slice(1).map((a) => copy(a.address, `Copy ${a.address}`, { section: "Copy" })), + ...(i.mac ? [copy(i.mac, "Copy MAC Address", { section: "Copy" })] : []), + ...(i.gateway ? [copy(i.gateway, "Copy Gateway", { section: "Copy" })] : []), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + ], + }; +} + +export async function interfaceItems(section?: string, all = true): Promise<Item[]> { + try { + const list = rankInterfaces(await interfaces()); + return (all ? list : list.filter((i) => i.isDefault)).map((i) => interfaceItem(i, section)); + } catch (e) { + if (e instanceof Missing) return [await missingItem("ip", "Listing interfaces", section)]; + return [errorItem("Can't list interfaces", e, section)]; + } +} + +// --------------------------------------------------------------------------- +// DNS + +const TYPE_TINT: Record<string, Color> = { + A: "blue", + AAAA: "purple", + CNAME: "teal", + MX: "orange", + NS: "green", + TXT: "gray", + SOA: "gray", + CAA: "pink", + SRV: "yellow", + PTR: "teal", +}; + +export function dnsItems(host: string, answers: (DnsAnswer & { via: string })[]): Item[] { + const items = answers.flatMap((a) => + a.records.map((r, n): Item => ({ + id: `dns:${a.type}:${n}:${r.data}`, + title: r.data || (a.type === "MX" ? "No mail accepted (null MX)" : "(empty)"), + subtitle: r.name !== host ? r.name : undefined, + icon: { source: Icons.hash, tint: TYPE_TINT[a.type] ?? "gray" }, + section: a.type, + keywords: [a.type], + accessories: [ + ...(r.priority !== undefined ? [{ text: `priority ${r.priority}` }] : []), + { tag: a.type }, + { text: formatTtl(r.ttl), tooltip: "Time to live" }, + ], + detail: { + markdown: a.type === "TXT" ? `\`\`\`\n${r.data}\n\`\`\`` : undefined, + metadata: [ + meta.text("Name", r.name, { icon: Icons.globe }), + meta.tags("Type", [{ text: a.type, color: TYPE_TINT[a.type] }]), + ...(a.type !== "TXT" ? [meta.text("Value", r.data)] : []), + ...(r.priority !== undefined ? [meta.text("Priority", r.priority)] : []), + meta.text("TTL", `${r.ttl} s (${formatTtl(r.ttl)})`, { icon: Icons.timer }), + meta.separator(), + meta.text("Resolver", a.via, { icon: Icons.server }), + ], + }, + actions: [ + copy(r.data, "Copy Value"), + copy( + answers.flatMap((x) => + x.records.map((y) => + `${y.name}\t${y.ttl}\tIN\t${x.type}\t${y.priority !== undefined ? `${y.priority} ` : ""}${y.data}` + ) + ).join("\n"), + "Copy All Records", + { section: "Copy" }, + ), + ...(["A", "AAAA"].includes(a.type) + ? [action(`Ping ${r.data}`, (c) => pingView(r.data, c), { icon: Icons.activity, section: "Tools" })] + : []), + ...(["CNAME", "MX", "NS"].includes(a.type) + ? [ + action(`Look Up ${r.data}`, (c) => dnsView(r.data, defaultTypes, c), { + icon: Icons.search, + section: "Tools", + }), + ] + : []), + ], + })) + ); + if (items.length) return items; + const status = answers.find((a) => a.status !== "NOERROR")?.status; + return [{ + id: "dns:none", + title: status === "NXDOMAIN" ? `${host} doesn't exist` : `No ${answers.map((a) => a.type).join(", ")} record`, + subtitle: status ?? "NOERROR, empty answer", + icon: { source: Icons.alert, tint: status === "NXDOMAIN" ? "red" : "gray" }, + detail: { metadata: [meta.text("Resolver", answers[0]?.via ?? "–", { icon: Icons.server })] }, + }]; +} + +export async function resolveAll(host: string, types: RecordType[], ctx: Context) { + return await Promise.all(types.map((t) => lookup(host, t, prefs(ctx).resolver ?? "cloudflare", ctx.signal))); +} + +export async function dnsResultItems(host: string, types: RecordType[], ctx: Context): Promise<Item[]> { + try { + return dnsItems(host, await resolveAll(host, types, ctx)); + } catch (e) { + if (e instanceof Missing) return [await missingItem("dig", "Looking up DNS through the system resolver")]; + throw e; + } +} + +export function dnsView(host: string, types: RecordType[], ctx: Context): ListSpec { + return list({ + title: `DNS · ${host}`, + placeholder: "Filter records…", + status: (items) => `${items.filter((i) => i.id !== "dns:none").length} records · ${host}`, + items: () => dnsResultItems(host, types, ctx), + }); +} + +// --------------------------------------------------------------------------- +// Ping + +export function pingView(host: string, ctx: Context): ListSpec { + const count = Math.max(1, Math.min(50, Number(prefs(ctx).pingCount) || 4)); + let summary = `Pinging ${host}…`; + return list({ + title: `Ping · ${host}`, + status: () => summary, + async *items({ signal }) { + try { + for await (const s of ping(host, count, signal)) { + const done = s.transmitted !== undefined; + const loss = s.loss ?? (s.replies.length ? 0 : undefined); + const q = latencyQuality(s.avg); + summary = done + ? `${s.received}/${s.transmitted} replies · ${s.loss}% loss · avg ${ms(s.avg)}` + : `${s.replies.length}/${count} replies…`; + const lossColor: Color = !loss ? "green" : loss < 50 ? "orange" : "red"; + const head: Item = { + id: "ping:summary", + title: s.errors.length && !s.replies.length + ? s.errors[0] + : done + ? `${q.text} · avg ${ms(s.avg)}` + : `Pinging ${host}…`, + subtitle: s.address && s.address !== host ? `${host} (${s.address})` : host, + icon: { + source: Icons.activity, + tint: s.errors.length && !s.replies.length ? "red" : done ? q.color : "blue", + }, + section: "Statistics", + accessories: done + ? [{ tag: `${s.loss}% loss`, color: lossColor }] + : [{ text: `${s.replies.length}/${count}` }], + detail: { + stats: [ + { label: "Min", value: ms(s.min) }, + { label: "Avg", value: ms(s.avg), color: s.avg !== undefined ? q.color : undefined }, + { label: "Max", value: ms(s.max) }, + { + label: "Loss", + value: loss === undefined ? "–" : `${loss} %`, + color: loss === undefined ? undefined : lossColor, + }, + ], + metadata: [ + meta.text("Host", host, { icon: Icons.globe }), + ...(s.address ? [meta.text("Address", s.address)] : []), + meta.progress( + "Replies", + s.transmitted ? (s.received ?? 0) / s.transmitted : s.replies.length / count, + `${s.received ?? s.replies.length} of ${s.transmitted ?? count}`, + ), + ...(s.mdev !== undefined ? [meta.text("Jitter (mdev)", ms(s.mdev), { icon: Icons.activity })] : []), + ...(s.replies[0]?.ttl ? [meta.text("TTL", s.replies[0].ttl!)] : []), + ...s.errors.map((e) => meta.text("Error", e, { icon: Icons.alert, color: "red" })), + ], + }, + actions: [ + action("Ping Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + copy( + `${host}${s.address ? ` (${s.address})` : ""}: min/avg/max ${ms(s.min)}/${ms(s.avg)}/${ms(s.max)}, ${ + loss ?? "?" + }% loss`, + "Copy Statistics", + { section: "Copy" }, + ), + ...(s.address ? [copy(s.address, "Copy Address", { section: "Copy" })] : []), + ], + }; + const replies = s.replies.map((r): Item => { + const rq = latencyQuality(r.time); + return { + id: `ping:${r.seq}`, + title: ms(r.time), + subtitle: `Reply ${r.seq}`, + icon: { source: Icons.dot, tint: rq.color }, + section: "Replies", + accessories: r.ttl ? [{ text: `ttl ${r.ttl}` }] : [], + actions: [action("Ping Again", () => refresh(), { icon: Icons.refresh })], + }; + }); + yield [head, ...replies]; + } + } catch (e) { + if (e instanceof Missing) yield [await missingItem("ping", "Pinging a host")]; + else throw e; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// Whois + +export async function whoisItems(host: string, ctx: Context): Promise<Item[]> { + let w; + try { + w = await whois(host, ctx.signal); + } catch (e) { + if (e instanceof Missing) return [await missingItem("whois", "Looking up the owner of a domain or IP")]; + throw e; + } + if (!w.fields.length && !w.nameServers.length) { + return [{ + id: "whois:none", + title: `No whois record for ${host}`, + subtitle: w.raw.split("\n").find((l) => l.trim() && !/^[%#]/.test(l))?.trim(), + icon: { source: Icons.alert, tint: "gray" }, + detail: { markdown: `\`\`\`\n${w.raw.slice(0, 4000)}\n\`\`\`` }, + }]; + } + const expires = w.fields.find((f) => f.label === "Expires")?.value; + const days = daysUntil(expires); + const raw = action("Show Raw Output", () => + detail({ + title: `whois ${host}`, + markdown: `\`\`\`\n${w.raw.slice(0, 20000)}\n\`\`\``, + actions: [copy(w.raw, "Copy Raw Output")], + }), { icon: Icons.text, shortcut: "<Ctrl>o" }); + const summary: Metadata[] = [ + ...w.fields.map((f) => meta.text(f.label, /Created|Updated|Expires/.test(f.label) ? shortDate(f.value) : f.value)), + ...(w.nameServers.length ? [meta.tags("Name servers", w.nameServers.slice(0, 6))] : []), + ...(w.statuses.length ? [meta.tags("Status", w.statuses.slice(0, 6))] : []), + ]; + const fieldItems = w.fields.map((f): Item => { + const date = /Created|Updated|Expires/.test(f.label); + const warn = f.label === "Expires" && days !== undefined && days < 30; + return { + id: `whois:${f.label}`, + title: date ? shortDate(f.value) : f.value, + subtitle: f.label, + icon: { + source: date + ? Icons.calendar + : f.label === "Registrar" + ? Icons.shield + : f.label === "Abuse" + ? Icons.mail + : Icons.info, + tint: warn ? "red" : "blue", + }, + section: isIP(host) ? "Network" : "Domain", + accessories: f.label === "Expires" && days !== undefined + ? [{ tag: days < 0 ? "Expired" : `in ${days} days`, color: days < 30 ? "red" : days < 90 ? "orange" : "green" }] + : [], + detail: { metadata: summary }, + actions: [copy(f.value, `Copy ${f.label}`), raw, copy(w.raw, "Copy Raw Output", { section: "Copy" })], + }; + }); + const nsItems = w.nameServers.map((ns): Item => ({ + id: `whois:ns:${ns}`, + title: ns, + subtitle: "Name server", + icon: { source: Icons.server, tint: "green" }, + section: "Name Servers", + detail: { metadata: summary }, + actions: [ + copy(ns, "Copy Name Server"), + action(`Look Up ${ns}`, (c) => dnsView(ns, ["A", "AAAA"], c), { icon: Icons.search }), + raw, + ], + })); + return [...fieldItems, ...nsItems]; +} + +export function whoisView(host: string, ctx: Context): ListSpec { + return list({ + title: `Whois · ${host}`, + placeholder: "Filter fields…", + status: `whois ${host}`, + items: () => whoisItems(host, ctx), + }); +} + +// --------------------------------------------------------------------------- +// Speed test + +export interface SpeedResult { + down: number; + up: number; + latency: number; + jitter: number; + at: number; +} + +export const lastSpeed = async () => (await storage.get("speed")) as unknown as SpeedResult | undefined; + +/** The speed the connection sustains: 90th percentile of the larger requests. */ +const sustained = (speeds: number[]) => percentile(speeds.length > 1 ? speeds.slice(1) : speeds, 0.9); + +export const speedLine = (r: SpeedResult) => `↓ ${formatSpeed(r.down)} · ↑ ${formatSpeed(r.up)} · ${ms(r.latency)}`; + +function speedItems(p: SpeedProgress): Item[] { + const done = p.phase === "done"; + const down = p.down.length ? sustained(p.down) : p.current ?? 0; + const up = p.up.length ? sustained(p.up) : 0; + const lat = median(p.latencies); + const jit = jitter(p.latencies); + const again = action("Run Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }); + const phaseTitle = { + latency: "Measuring latency…", + download: "Measuring download…", + upload: "Measuring upload…", + done: "Done", + }[p.phase]; + const common: Metadata[] = [ + meta.progress("Progress", p.progress, done ? "Done" : phaseTitle), + meta.text("Downloaded", formatBytes(p.bytesDown), { icon: Icons.download }), + meta.text("Uploaded", formatBytes(p.bytesUp), { icon: Icons.upload }), + meta.separator(), + meta.link("Server", "speed.cloudflare.com", "https://speed.cloudflare.com"), + ]; + const stats = [ + { label: "Download", value: down ? formatSpeed(down) : "…", color: "blue" as Color }, + { label: "Upload", value: up ? formatSpeed(up) : "…", color: "purple" as Color }, + { label: "Latency", value: p.latencies.length ? ms(lat) : "…" }, + { label: "Jitter", value: p.latencies.length > 1 ? ms(jit) : "…" }, + ]; + const summary = done ? `↓ ${formatSpeed(down)} · ↑ ${formatSpeed(up)} · ${ms(lat)} latency` : ""; + const row = ( + id: string, + title: string, + value: string, + icon: string, + tint: Color, + active: boolean, + finished: boolean, + extra: Metadata[] = [], + ): Item => ({ + id, + title, + subtitle: value, + icon: { source: icon, tint: finished ? tint : active ? tint : "gray" }, + section: "Speed Test", + accessories: active && !done + ? [{ tag: "Measuring…", color: "blue" }] + : finished + ? [{ icon: { source: Icons.check, tint: "green" } }] + : [], + detail: { stats, markdown: done ? speedVerdict(down) : undefined, metadata: [...extra, ...common] }, + actions: [again, ...(summary ? [copy(summary, "Copy Results")] : [])], + }); + return [ + row( + "download", + "Download", + down ? formatSpeed(down) : "Waiting", + Icons.download, + "blue", + p.phase === "download", + p.down.length > 0 && p.phase !== "download", + p.down.map((s, i) => meta.text(`Request ${i + 1}`, formatSpeed(s))), + ), + row( + "upload", + "Upload", + up ? formatSpeed(up) : "Waiting", + Icons.upload, + "purple", + p.phase === "upload", + done, + p.up.map((s, i) => meta.text(`Request ${i + 1}`, formatSpeed(s))), + ), + row( + "latency", + "Latency", + p.latencies.length ? `${ms(lat)} · jitter ${ms(jit)}` : "Measuring…", + Icons.activity, + latencyQuality(lat).color, + p.phase === "latency", + p.phase !== "latency", + [ + meta.text("Quality", latencyQuality(lat).text, { color: latencyQuality(lat).color }), + meta.text("Samples", p.latencies.map((l) => l.toFixed(0)).join(", ")), + ], + ), + ]; +} + +export function speedView(): ListSpec { + let status = "Measuring latency…"; + return list({ + title: "Speed Test", + status: () => status, + async *items({ signal }) { + let last: SpeedProgress | undefined; + try { + for await (const p of speedTest(signal)) { + last = p; + status = p.phase === "done" + ? `Done · ${formatBytes(p.bytesDown + p.bytesUp)} transferred` + : `${Math.round(p.progress * 100)} % · ${p.phase === "latency" ? "latency" : p.phase}`; + yield speedItems(p); + } + } catch (e) { + if (signal.aborted) return; + yield [errorItem("Speed test failed", e), ...(last ? speedItems(last) : [])]; + return; + } + if (last) { + const r: SpeedResult = { + down: sustained(last.down), + up: sustained(last.up), + latency: median(last.latencies), + jitter: jitter(last.latencies), + at: Date.now(), + }; + await storage.set("speed", r as any); + updateCommandMetadata({ subtitle: speedLine(r) }).catch(() => {}); + } + }, + }); +} diff --git a/extensions/ollama/backslash.toml b/extensions/ollama/backslash.toml new file mode 100644 index 0000000..c03f307 --- /dev/null +++ b/extensions/ollama/backslash.toml @@ -0,0 +1,34 @@ +[extension] +id = "ollama" +title = "Ollama Models" +description = "See the local Ollama models and the ones loaded in memory, pull new ones with live progress and delete them" +version = "1.0.0" +author = "Backslash" +icon = "bs-sparkle" +license = "MIT" + +[permissions] +# The local Ollama server (its default address). +net = ["127.0.0.1:11434"] +# ollama --version tells "not installed" from "not running"; systemd-run starts +# `ollama serve` in the background when asked. +run = ["ollama", "systemd-run"] +read = ["/etc/os-release"] + +[[commands]] +name = "models" +title = "Ollama Models" +description = "Installed and running models: size, family, parameters, quantization, memory" +icon = "bs-sparkle" +keywords = ["ollama", "llm", "ai", "model", "local", "llama", "qwen", "gemma", "mistral", "modèle", "ia"] + +[[commands]] +name = "pull" +title = "Pull Ollama Model" +description = "Download a model from the Ollama library with live progress" +icon = "bs-download" +keywords = ["ollama", "pull", "download", "model", "llm", "install", "télécharger", "modèle"] + +[[commands.arguments]] +name = "model" +placeholder = "llama3.2, qwen3:8b…" diff --git a/extensions/ollama/commands/models.ts b/extensions/ollama/commands/models.ts new file mode 100644 index 0000000..8a3ce8a --- /dev/null +++ b/extensions/ollama/commands/models.ts @@ -0,0 +1,232 @@ +import { + type Action, + action, + copy, + detail, + Icons, + type Item, + list, + meta, + type Metadata, + open, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + age, + bytes, + count, + displayName, + expiresIn, + libraryUrl, + type Model, + type ModelInfo, + parsePs, + parseShow, + parseTags, + processor, + type Running, +} from "../lib.ts"; +import { api, guarded, nextChange, notifyChanged, pullForm, pullView } from "../ollama.ts"; + +const CAPABILITY_COLORS: Record<string, "blue" | "purple" | "green" | "orange" | "teal" | "gray"> = { + completion: "gray", + vision: "purple", + tools: "blue", + thinking: "orange", + embedding: "teal", + insert: "green", +}; + +function infoView(m: Model, info: ModelInfo | undefined, raw: { modelfile?: string } | undefined) { + const lines = [ + `# ${displayName(m.name)}`, + "", + info?.system ? `**System prompt:** ${info.system}\n` : "", + info?.parameters.length ? "## Parameters\n\n```\n" + info.parameters.join("\n") + "\n```\n" : "", + raw?.modelfile ? "## Modelfile\n\n```\n" + raw.modelfile.replace(/```/g, "ʼʼʼ").slice(0, 6000) + "\n```" : "", + ]; + return detail({ + title: displayName(m.name), + markdown: lines.filter(Boolean).join("\n"), + metadata: metadata(m, info), + actions: [ + copy(`ollama run ${m.name}`, "Copy ollama run Command", { icon: Icons.terminal }), + ...(raw?.modelfile ? [copy(raw.modelfile, "Copy Modelfile", { icon: Icons.file })] : []), + open(libraryUrl(m.name), "Open in Ollama Library"), + ], + }); +} + +function metadata(m: Model, info?: ModelInfo, r?: Running): Metadata[] { + return [ + ...(r + ? [ + meta.text("Loaded", processor(r), { icon: Icons.cpu, color: "green" }), + meta.text("Memory", bytes(r.size), { icon: Icons.layers }), + meta.text("Unloads", expiresIn(r.expires), { icon: Icons.timer }), + ...(r.context ? [meta.text("Context in use", r.context.toLocaleString("en-US"))] : []), + meta.separator(), + ] + : []), + ...(m.family ? [meta.text("Family", m.family, { icon: Icons.layers })] : []), + ...(m.parameters ? [meta.text("Parameters", m.parameters, { icon: Icons.cpu })] : []), + ...(m.quantization ? [meta.text("Quantization", m.quantization)] : []), + ...(info?.contextLength ? [meta.text("Context window", info.contextLength.toLocaleString("en-US"))] : []), + ...(info?.capabilities.length + ? [meta.tags("Capabilities", info.capabilities.map((c) => ({ text: c, color: CAPABILITY_COLORS[c] ?? "gray" })))] + : []), + meta.separator(), + meta.text("Size on disk", m.remote ? "Cloud model" : bytes(m.size), { icon: Icons.drive }), + ...(m.format ? [meta.text("Format", m.format.toUpperCase())] : []), + ...(info?.license ? [meta.text("License", info.license, { icon: Icons.book })] : []), + meta.text("Digest", m.digest.slice(0, 12), { icon: Icons.hash }), + ...(m.modified ? [meta.text("Updated", age(m.modified) + " ago", { icon: Icons.calendar })] : []), + ]; +} + +function item(m: Model, info: ModelInfo | undefined, r: Running | undefined): Item { + const actions: Action[] = [ + copy(`ollama run ${m.name}`, "Copy ollama run Command", { icon: Icons.terminal }), + action("Show Details", async () => { + const raw = await api<any>("/api/show", { body: { model: m.name } }).catch(() => undefined); + return infoView(m, raw ? parseShow(raw) : info, raw); + }, { icon: Icons.info, shortcut: "<Ctrl>i" }), + ]; + if (r) { + actions.push(action("Unload from Memory", async () => { + await api("/api/generate", { body: { model: m.name, keep_alive: 0 } }); + notifyChanged(); + await toast(`Unloaded ${displayName(m.name)}`, { style: "success" }); + }, { icon: Icons.stop, shortcut: "<Ctrl>u", section: "Model" })); + } else if (!info?.capabilities.includes("embedding")) { + actions.push(action("Load into Memory", async () => { + await toast(`Loading ${displayName(m.name)}…`); + await api("/api/generate", { body: { model: m.name, keep_alive: "10m" } }); + notifyChanged(); + await toast(`${displayName(m.name)} is loaded for 10 minutes`, { style: "success" }); + }, { icon: Icons.play, shortcut: "<Ctrl>l", section: "Model" })); + } + actions.push( + ...(m.remote ? [] : [action("Update (Pull Again)", () => pullView(m.name), { + icon: Icons.download, + section: "Model", + })]), + action("Pull Another Model…", () => pullForm(), { icon: Icons.plus, shortcut: "<Ctrl>n", section: "Model" }), + open(libraryUrl(m.name), "Open in Ollama Library", { icon: Icons.globe, shortcut: "<Ctrl>o", section: "Model" }), + copy(m.name, "Copy Model Name", { shortcut: "<Ctrl>period", section: "Copy" }), + copy(`ollama pull ${m.name}`, "Copy ollama pull Command", { icon: Icons.terminal, section: "Copy" }), + action("Delete Model", async () => { + await api("/api/delete", { method: "DELETE", body: { model: m.name } }); + notifyChanged(); + await toast(`Deleted ${displayName(m.name)}`, { + style: "success", + message: m.remote ? undefined : `${bytes(m.size)} freed`, + }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${displayName(m.name)}${ + m.remote ? "" : ` (${bytes(m.size)})` + }? Pulling it again downloads it again.`, + shortcut: "<Ctrl>Delete", + section: "Danger", + }), + ); + const caps = (info?.capabilities ?? []).filter((c) => c !== "completion"); + return { + id: m.name, + title: displayName(m.name), + subtitle: [m.family, m.parameters, m.quantization].filter(Boolean).join(" · "), + icon: { source: m.remote ? Icons.cloud : Icons.sparkle, tint: r ? "green" : m.remote ? "blue" : "purple" }, + section: r ? "Running" : "Installed", + keywords: [m.family ?? "", ...m.families, m.parameters ?? "", m.quantization ?? "", ...caps], + accessories: [ + ...caps.slice(0, 2).map((c) => ({ tag: c, color: CAPABILITY_COLORS[c] ?? "gray" as const })), + ...(r + ? [{ icon: { source: Icons.timer, tint: "green" as const }, text: expiresIn(r.expires), tooltip: "Unloads" }] + : []), + { text: m.remote ? "cloud" : bytes(m.size), tooltip: m.remote ? "Runs on ollama.com" : "Size on disk" }, + ], + detail: { + stats: r + ? [ + { label: "memory", value: bytes(r.size), color: "green" }, + { label: "on GPU", value: r.size ? `${Math.round((r.vram / r.size) * 100)}%` : "—" }, + { label: "unloads", value: expiresIn(r.expires).replace(/^in /, "") || "—" }, + ] + : [ + { label: "parameters", value: m.parameters || count(info?.parameterCount) || "—" }, + { label: "size", value: m.remote ? "cloud" : bytes(m.size) }, + { label: "quant", value: m.quantization ?? "—" }, + ], + metadata: metadata(m, info, r), + }, + actions, + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter models by name, family, size…", + empty: { + title: "No models yet", + description: "Press Ctrl+N to pull one, e.g. llama3.2 or qwen3", + icon: Icons.sparkle, + }, + status: () => summary, + actions: [action("Pull a Model…", () => pullForm(), { icon: Icons.download, shortcut: "<Ctrl>n" })], + async *items({ signal }) { + const infos = new Map<string, ModelInfo>(); + while (!signal.aborted) { + let models: Model[] = []; + let running: Running[] = []; + let version = ""; + const items = await guarded(async () => { + const [tags, ps, v] = await Promise.all([ + api<any>("/api/tags", { signal }), + api<any>("/api/ps", { signal }), + api<{ version: string }>("/api/version", { signal }).catch(() => ({ version: "" })), + ]); + models = parseTags(tags); + running = parsePs(ps); + version = v.version; + return build(models, running, infos); + }); + const total = models.reduce((n, m) => n + (m.remote ? 0 : m.size), 0); + summary = models.length + ? `${models.length} model${models.length === 1 ? "" : "s"} · ${bytes(total)}${ + running.length ? ` · ${running.length} running` : "" + }${version ? ` · Ollama ${version}` : ""}` + : ""; + await updateCommandMetadata({ + subtitle: models.length + ? `${models.length} models${running.length ? ` · ${running.length} running` : ""}` + : null, + }).catch(() => {}); + yield items; + + // Capabilities and context windows, once per model. + const missing = models.filter((m) => !infos.has(m.name)); + if (missing.length) { + await Promise.all(missing.map(async (m) => { + const raw = await api<any>("/api/show", { body: { model: m.name }, signal }).catch(() => undefined); + if (raw) infos.set(m.name, parseShow(raw)); + })); + if (signal.aborted) return; + yield build(models, running, infos); + } + // Running models unload by themselves: keep their countdown fresh. + await nextChange(signal, running.length ? 15_000 : 60_000); + } + }, +}); + +function build(models: Model[], running: Running[], infos: Map<string, ModelInfo>): Item[] { + const byName = new Map(running.map((r) => [r.name, r])); + return [ + ...models.filter((m) => byName.has(m.name)), + ...models.filter((m) => !byName.has(m.name)), + ].map((m) => item(m, infos.get(m.name), byName.get(m.name))); +} diff --git a/extensions/ollama/commands/pull.ts b/extensions/ollama/commands/pull.ts new file mode 100644 index 0000000..d67bfbe --- /dev/null +++ b/extensions/ollama/commands/pull.ts @@ -0,0 +1,9 @@ +import type { Context } from "@backslash/api"; +import { normalizeModelName } from "../lib.ts"; +import { pullForm, pullView } from "../ollama.ts"; + +// "Pull Ollama Model qwen3:8b" streams the download; without a name, asks for one. +export default (ctx: Context<{ model?: string }>) => { + const name = String(ctx.args.model ?? ctx.query ?? "").trim(); + return name ? pullView(normalizeModelName(name)) : pullForm(); +}; diff --git a/extensions/ollama/lib.ts b/extensions/ollama/lib.ts new file mode 100644 index 0000000..3695d94 --- /dev/null +++ b/extensions/ollama/lib.ts @@ -0,0 +1,248 @@ +// Pure logic of the Ollama extension: API response mapping, pull progress, +// formatting and install hints. No I/O here. + +export const BASE = "http://127.0.0.1:11434"; + +export interface Model { + name: string; + size: number; + digest: string; + modified?: Date; + family?: string; + families: string[]; + parameters?: string; + quantization?: string; + format?: string; + /** Cloud models run on ollama.com and take no disk space. */ + remote: boolean; +} + +export function mapModel(m: any): Model { + const d = m.details ?? {}; + return { + name: m.name ?? m.model, + size: Number(m.size ?? 0), + digest: String(m.digest ?? ""), + modified: m.modified_at ? new Date(m.modified_at) : undefined, + family: d.family || undefined, + families: d.families ?? (d.family ? [d.family] : []), + parameters: d.parameter_size || undefined, + quantization: d.quantization_level || undefined, + format: d.format || undefined, + remote: !!m.remote_host || /-cloud$|:cloud$/.test(m.name ?? ""), + }; +} + +export function parseTags(body: { models?: unknown[] } | undefined): Model[] { + return (body?.models ?? []).map(mapModel).sort((a, b) => a.name.localeCompare(b.name)); +} + +export interface Running { + name: string; + size: number; + vram: number; + expires?: Date; + context?: number; +} + +export function parsePs(body: { models?: any[] } | undefined): Running[] { + return (body?.models ?? []).map((m) => ({ + name: m.name ?? m.model, + size: Number(m.size ?? 0), + vram: Number(m.size_vram ?? 0), + expires: m.expires_at ? new Date(m.expires_at) : undefined, + context: m.context_length || undefined, + })); +} + +/** `100% GPU`, `48%/52% CPU/GPU` like `ollama ps`, or `100% CPU`. */ +export function processor(r: Running): string { + if (!r.size) return ""; + const gpu = Math.round((r.vram / r.size) * 100); + if (gpu >= 100) return "100% GPU"; + if (gpu <= 0) return "100% CPU"; + return `${100 - gpu}%/${gpu}% CPU/GPU`; +} + +/** `in 4 min`, `in 2 h`, `forever` (keep_alive -1 sets a date centuries away). */ +export function expiresIn(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = (date.getTime() - now.getTime()) / 1000; + if (s > 86400 * 365) return "forever"; + if (s <= 0) return "now"; + if (s < 60) return `in ${Math.round(s)} s`; + if (s < 3600) return `in ${Math.round(s / 60)} min`; + if (s < 86400) return `in ${Math.round(s / 3600)} h`; + return `in ${Math.round(s / 86400)} d`; +} + +export interface ModelInfo { + architecture?: string; + parameterCount?: number; + contextLength?: number; + embeddingLength?: number; + capabilities: string[]; + license?: string; + parameters: string[]; + template?: string; + system?: string; +} + +export function parseShow(body: any): ModelInfo { + const info = body?.model_info ?? {}; + const arch = info["general.architecture"]; + const license = typeof body?.license === "string" ? body.license.trim().split("\n")[0].trim() : undefined; + const system = /^SYSTEM\s+"""([\s\S]*?)"""/m.exec(body?.modelfile ?? "")?.[1] ?? + /^SYSTEM\s+(.+)$/m.exec(body?.modelfile ?? "")?.[1]; + return { + architecture: arch, + parameterCount: info["general.parameter_count"], + contextLength: arch ? info[`${arch}.context_length`] : undefined, + embeddingLength: arch ? info[`${arch}.embedding_length`] : undefined, + capabilities: body?.capabilities ?? [], + license: license || undefined, + parameters: String(body?.parameters ?? "").split("\n").map((l) => l.trim().replace(/\s+/g, " ")).filter(Boolean), + template: body?.template || undefined, + system: system?.trim() || undefined, + }; +} + +/** `llama3.2:latest` → `llama3.2`; `qwen3:8b` stays. */ +export const displayName = (name: string) => name.replace(/:latest$/, ""); + +/** The library page of a model (namespaced models live under their user). */ +export function libraryUrl(name: string): string { + const base = name.split(":")[0]; + return base.includes("/") ? `https://ollama.com/${base}` : `https://ollama.com/library/${base}`; +} + +/** Validates a model name typed by the user. */ +export function normalizeModelName(input: string): string { + let name = input.trim().replace(/^ollama\s+(run|pull)\s+/, ""); + name = name.replace(/^https?:\/\/ollama\.com\/(library\/)?/, ""); + if (!/^[\w.-]+(\/[\w.-]+)?(:[\w.-]+)?$/.test(name)) { + throw new Error(`“${input.trim()}” is not a model name (e.g. llama3.2, qwen3:8b, gemma3:4b)`); + } + return name; +} + +// --- Pull progress ----------------------------------------------------------------- + +export interface Layer { + digest: string; + total: number; + completed: number; +} + +export interface PullState { + status: string; + layers: Layer[]; + done: boolean; + error?: string; +} + +export const initialPull = (): PullState => ({ status: "Starting", layers: [], done: false }); + +/** Folds one NDJSON line of `/api/pull` into the state. */ +export function reducePull(state: PullState, line: { + status?: string; + digest?: string; + total?: number; + completed?: number; + error?: string; +}): PullState { + if (line.error) return { ...state, error: line.error, done: true }; + const layers = [...state.layers]; + if (line.digest && line.total) { + const i = layers.findIndex((l) => l.digest === line.digest); + const layer = { digest: line.digest, total: line.total, completed: line.completed ?? 0 }; + if (i < 0) layers.push(layer); + else layers[i] = { ...layer, completed: Math.max(layers[i].completed, layer.completed) }; + } + const status = line.status ?? state.status; + return { status, layers, done: status === "success" }; +} + +export function pullTotals(state: PullState): { completed: number; total: number; ratio: number } { + const total = state.layers.reduce((n, l) => n + l.total, 0); + const completed = state.layers.reduce((n, l) => n + Math.min(l.completed, l.total), 0); + return { completed, total, ratio: total ? completed / total : state.done ? 1 : 0 }; +} + +/** `pulling 6a0746a1ec1a` → `Downloading layer 6a0746a1`. */ +export function statusLabel(status: string): string { + if (status === "success") return "Done"; + if (status === "pulling manifest") return "Fetching manifest"; + const m = /^pulling ([0-9a-f]{8,})/.exec(status); + if (m) return `Downloading layer ${m[1].slice(0, 8)}`; + if (status.startsWith("verifying")) return "Verifying digest"; + if (status.startsWith("writing manifest")) return "Writing manifest"; + if (status.startsWith("removing")) return "Removing unused layers"; + return status.charAt(0).toUpperCase() + status.slice(1); +} + +/** Splits a stream of chunks into JSON lines. */ +export function splitLines(buffer: string): { lines: string[]; rest: string } { + const parts = buffer.split("\n"); + const rest = parts.pop() ?? ""; + return { lines: parts.map((l) => l.trim()).filter(Boolean), rest }; +} + +// --- Formatting ------------------------------------------------------------------------- + +export function bytes(n: number): string { + if (n < 1000) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n; + let i = -1; + do { + v /= 1000; + i++; + } while (v >= 1000 && i < units.length - 1); + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** 3212749888 → `3.2B`. */ +export function count(n: number | undefined): string { + if (!n) return ""; + if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(0)}M`; + return `${Math.round(n / 1e3)}K`; +} + +export function age(date: Date | undefined, now = new Date()): string { + if (!date || isNaN(date.getTime())) return ""; + const s = Math.max(0, (now.getTime() - date.getTime()) / 1000); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`; + return `${Math.floor(s / (86400 * 365))}y`; +} + +// --- Installing ---------------------------------------------------------------------------- + +export type Family = "debian" | "fedora" | "arch" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +export const INSTALL_SCRIPT = "curl -fsSL https://ollama.com/install.sh | sh"; + +export function installCommand(family: Family | undefined): string { + switch (family) { + case "arch": + return "sudo pacman -S ollama # or ollama-cuda, ollama-rocm"; + case "fedora": + return "sudo dnf install ollama"; + default: + return INSTALL_SCRIPT; + } +} diff --git a/extensions/ollama/lib_test.ts b/extensions/ollama/lib_test.ts new file mode 100644 index 0000000..054dabc --- /dev/null +++ b/extensions/ollama/lib_test.ts @@ -0,0 +1,144 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + bytes, + count, + displayName, + expiresIn, + initialPull, + installCommand, + libraryUrl, + normalizeModelName, + osFamily, + parsePs, + parseShow, + parseTags, + processor, + pullTotals, + reducePull, + splitLines, + statusLabel, +} from "./lib.ts"; + +const TAGS = { + models: [ + { + name: "qwen3:8b", + model: "qwen3:8b", + modified_at: "2026-09-01T10:00:00Z", + size: 5225388164, + digest: "500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41", + details: { + format: "gguf", + family: "qwen3", + families: ["qwen3"], + parameter_size: "8.2B", + quantization_level: "Q4_K_M", + }, + }, + { + name: "llama3.2:latest", + model: "llama3.2:latest", + modified_at: "2026-10-20T10:00:00.123+02:00", + size: 2019393189, + digest: "a80c4f17acd5", + details: { format: "gguf", family: "llama", parameter_size: "3.2B", quantization_level: "Q4_K_M" }, + }, + { name: "gpt-oss:120b-cloud", size: 384, digest: "x", remote_host: "https://ollama.com:443", details: {} }, + ], +}; + +Deno.test("installed models", () => { + const models = parseTags(TAGS); + assertEquals(models.map((m) => m.name), ["gpt-oss:120b-cloud", "llama3.2:latest", "qwen3:8b"]); + assertEquals(models[1].families, ["llama"]); + assertEquals(models[2].parameters, "8.2B"); + assertEquals(models[0].remote, true); + assertEquals(models[1].remote, false); + assertEquals(parseTags(undefined), []); + assertEquals(displayName("llama3.2:latest"), "llama3.2"); + assertEquals(displayName("qwen3:8b"), "qwen3:8b"); +}); + +Deno.test("running models", () => { + const [r] = parsePs({ + models: [{ + name: "llama3.2:latest", + size: 3500000000, + size_vram: 1680000000, + expires_at: "2026-10-28T12:04:00Z", + context_length: 4096, + }], + }); + assertEquals(processor(r), "52%/48% CPU/GPU"); + assertEquals(processor({ ...r, vram: r.size }), "100% GPU"); + assertEquals(processor({ ...r, vram: 0 }), "100% CPU"); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals(expiresIn(r.expires, now), "in 4 min"); + assertEquals(expiresIn(new Date("2318-01-01T00:00:00Z"), now), "forever"); + assertEquals(expiresIn(new Date("2026-10-28T11:00:00Z"), now), "now"); +}); + +Deno.test("model details from /api/show", () => { + const info = parseShow({ + license: "\nMIT License\n\nCopyright", + modelfile: 'FROM x\nSYSTEM """You are a pirate."""\nPARAMETER temperature 0.7', + parameters: 'temperature 0.7\nstop "<|im_end|>"', + capabilities: ["completion", "vision"], + model_info: { + "general.architecture": "gemma3", + "general.parameter_count": 4300079472, + "gemma3.context_length": 131072, + }, + }); + assertEquals(info.contextLength, 131072); + assertEquals(info.license, "MIT License"); + assertEquals(info.system, "You are a pirate."); + assertEquals(info.parameters, ["temperature 0.7", 'stop "<|im_end|>"']); + assertEquals(count(info.parameterCount), "4.3B"); + assertEquals(count(137000000), "137M"); + assertEquals(parseShow(undefined).capabilities, []); +}); + +Deno.test("model names", () => { + assertEquals(normalizeModelName(" ollama run qwen3:8b "), "qwen3:8b"); + assertEquals(normalizeModelName("https://ollama.com/library/gemma3"), "gemma3"); + assertEquals(normalizeModelName("hf.co/user"), "hf.co/user"); + assertThrows(() => normalizeModelName("rm -rf /"), Error, "not a model name"); + assertEquals(libraryUrl("qwen3:8b"), "https://ollama.com/library/qwen3"); + assertEquals(libraryUrl("mannix/llama3:8b"), "https://ollama.com/mannix/llama3"); +}); + +Deno.test("pull progress", () => { + const lines = [ + { status: "pulling manifest" }, + { status: "pulling dde5aa3fc5ff", digest: "sha256:dde5", total: 2000, completed: 500 }, + { status: "pulling 966de95ca8a6", digest: "sha256:966d", total: 1000 }, + { status: "pulling dde5aa3fc5ff", digest: "sha256:dde5", total: 2000, completed: 400 }, // out of order: kept at 500 + { status: "pulling 966de95ca8a6", digest: "sha256:966d", total: 1000, completed: 1000 }, + ]; + let s = initialPull(); + for (const l of lines) s = reducePull(s, l); + assertEquals(s.layers.map((l) => l.completed), [500, 1000]); + assertEquals(pullTotals(s), { completed: 1500, total: 3000, ratio: 0.5 }); + assertEquals(statusLabel(s.status), "Downloading layer 966de95c"); + s = reducePull(reducePull(s, { status: "verifying sha256 digest" }), { status: "success" }); + assertEquals(s.done, true); + assertEquals(statusLabel(s.status), "Done"); + const failed = reducePull(initialPull(), { error: "pull model manifest: file does not exist" }); + assertEquals([failed.done, failed.error], [true, "pull model manifest: file does not exist"]); + assertEquals(pullTotals(initialPull()).ratio, 0); +}); + +Deno.test("NDJSON splitting", () => { + assertEquals(splitLines('{"a":1}\n{"b":2}\n{"c"'), { lines: ['{"a":1}', '{"b":2}'], rest: '{"c"' }); + assertEquals(splitLines(""), { lines: [], rest: "" }); +}); + +Deno.test("formatting and install hints", () => { + assertEquals(bytes(2019393189), "2.0 GB"); + assertEquals(bytes(274302450), "274 MB"); + assertEquals(bytes(512), "512 B"); + assertEquals(installCommand(osFamily("ID=arch")), "sudo pacman -S ollama # or ollama-cuda, ollama-rocm"); + assertEquals(installCommand(osFamily('ID="fedora"')), "sudo dnf install ollama"); + assertEquals(installCommand(osFamily("ID=ubuntu\nID_LIKE=debian")), "curl -fsSL https://ollama.com/install.sh | sh"); +}); diff --git a/extensions/ollama/ollama.ts b/extensions/ollama/ollama.ts new file mode 100644 index 0000000..20b2010 --- /dev/null +++ b/extensions/ollama/ollama.ts @@ -0,0 +1,346 @@ +// Talking to the local Ollama server, and the views both commands share. + +import { + action, + close, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + refresh, +} from "@backslash/api"; +import { + BASE, + bytes, + displayName, + initialPull, + INSTALL_SCRIPT, + installCommand, + libraryUrl, + normalizeModelName, + osFamily, + pullTotals, + reducePull, + splitLines, + statusLabel, +} from "./lib.ts"; + +export class NotRunning extends Error {} + +export async function api<T>( + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise<T> { + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + method: options.method ?? (options.body === undefined ? "GET" : "POST"), + signal: options.signal, + headers: options.body === undefined ? {} : { "Content-Type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (err) { + if (options.signal?.aborted) throw err; + throw new NotRunning(`Ollama isn't running on ${BASE.replace(/^http:\/\//, "")}`); + } + const text = await res.text(); + const body = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new Error(body?.error ?? `Ollama answered ${res.status}`); + return body as T; +} + +// --- Live reload ------------------------------------------------------------------------ + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Resolves on the next change, after `ms`, or when `signal` aborts. */ +export function nextChange(signal: AbortSignal, ms: number): Promise<void> { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + } + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); +} + +// --- Not running -------------------------------------------------------------------------- + +async function installed(): Promise<boolean> { + try { + await new Deno.Command("ollama", { args: ["--version"], stdin: "null", stdout: "null", stderr: "null" }).output(); + return true; + } catch { + return false; + } +} + +export async function notRunningItems(err: NotRunning): Promise<Item[]> { + const [hasBinary, osRelease] = await Promise.all([ + installed(), + Deno.readTextFile("/etc/os-release").catch(() => ""), + ]); + const install = installCommand(osFamily(osRelease)); + if (hasBinary) { + return [{ + id: "not-running", + title: err.message, + subtitle: "Start the server, then reload", + icon: { source: Icons.power, tint: "orange" }, + detail: { + markdown: [ + "Ollama is installed but its server doesn't answer.", + "", + "```sh", + "sudo systemctl start ollama # the service installed by the official script", + "ollama serve # or in a terminal", + "```", + "", + "**Start ollama serve** runs it in the background for your session (`systemd-run --user`).", + ].join("\n"), + }, + actions: [ + action("Start ollama serve", async () => { + const out = await new Deno.Command("systemd-run", { + args: ["--user", "--collect", "--unit=backslash-ollama-serve", "ollama", "serve"], + stdin: "null", + stdout: "null", + stderr: "piped", + }).output().catch((err) => { + if (err instanceof Deno.errors.NotFound) { + throw new Error("systemd-run is missing: run ollama serve in a terminal"); + } + throw err; + }); + if (!out.success) throw new Error(new TextDecoder().decode(out.stderr).trim() || "systemd-run failed"); + await new Promise((r) => setTimeout(r, 1500)); + return refresh(); + }, { icon: Icons.play }), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + copy("sudo systemctl start ollama", "Copy systemctl Command", { icon: Icons.terminal, section: "Copy" }), + ], + }]; + } + return [{ + id: "not-installed", + title: "Ollama is not installed", + subtitle: install, + icon: { source: Icons.download, tint: "orange" }, + detail: { + markdown: [ + "Ollama runs language models on this computer. Install it, then pull a model:", + "", + "```sh", + install, + "ollama pull llama3.2", + "```", + ].join("\n"), + }, + actions: [ + copy(install.replace(/\s+#.*$/, ""), "Copy Install Command", { icon: Icons.terminal }), + ...(install === INSTALL_SCRIPT + ? [] + : [copy(INSTALL_SCRIPT, "Copy Install Script Command", { icon: Icons.terminal })]), + open("https://ollama.com/download/linux", "Open Download Page"), + action("Reload", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + ], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (err) { + if (err instanceof NotRunning) return notRunningItems(err); + throw err; + } +} + +// --- Pull -------------------------------------------------------------------------------- + +export function pullForm(): FormSpec { + return form({ + title: "Pull a Model", + submitTitle: "Pull", + fields: [ + field.text("model", "Model", { required: true, placeholder: "llama3.2, qwen3:8b, gemma3:4b, deepseek-r1:7b…" }), + ], + submit: (values) => pullView(normalizeModelName(String(values.model ?? ""))), + }); +} + +/** Streams `/api/pull` into items: the overall progress, then one row per layer. */ +export function pullView(name: string): ListSpec { + let line = ""; + return list({ + title: `Pull ${name}`, + placeholder: `Pulling ${name}…`, + status: () => line, + async *items({ signal }) { + let state = initialPull(); + const started = Date.now(); + let res: Response; + try { + res = await fetch(`${BASE}/api/pull`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: name, stream: true }), + }); + } catch (err) { + if (signal.aborted) return; + console.error(err); + yield await notRunningItems(new NotRunning(`Ollama isn't running on ${BASE.replace(/^http:\/\//, "")}`)); + return; + } + if (!res.ok || !res.body) { + const body = await res.json().catch(() => undefined); + line = "Failed"; + yield [failedItem(name, body?.error ?? `Ollama answered ${res.status}`)]; + return; + } + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); + let rest = ""; + let lastYield = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const split = splitLines(rest + value); + rest = split.rest; + for (const l of split.lines) { + try { + state = reducePull(state, JSON.parse(l)); + } catch { + // A partial or odd line: keep going. + } + } + // A few updates a second are enough for a progress bar. + if (Date.now() - lastYield > 250 || state.done) { + lastYield = Date.now(); + const t = pullTotals(state); + const secs = (Date.now() - started) / 1000; + const speed = secs > 1 && t.completed ? `${bytes(t.completed / secs)}/s` : ""; + line = state.error + ? "Failed" + : `${statusLabel(state.status)}${t.total ? ` · ${bytes(t.completed)} of ${bytes(t.total)}` : ""}${ + speed && !state.done ? ` · ${speed}` : "" + }`; + yield progressItems(name, state, speed); + } + if (state.done) break; + } + if (state.error) { + line = "Failed"; + yield [failedItem(name, state.error)]; + } else if (!state.done && !signal.aborted) { + yield [failedItem(name, "The download stopped before it finished")]; + } else if (state.done) { + notifyChanged(); + const t = pullTotals(state); + line = `Done in ${Math.round((Date.now() - started) / 1000)} s${t.total ? ` · ${bytes(t.total)}` : ""}`; + yield [readyItem(name)]; + } + }, + }); +} + +function progressItems(name: string, state: ReturnType<typeof initialPull>, speed: string): Item[] { + const t = pullTotals(state); + const pct = `${Math.floor(t.ratio * 100)}%`; + const overall: Item = { + id: "overall", + title: state.done ? `${displayName(name)} is ready` : `Pulling ${displayName(name)}`, + subtitle: statusLabel(state.status), + icon: { source: state.done ? Icons.done : Icons.download, tint: state.done ? "green" : "blue" }, + section: "Progress", + accessories: [...(speed && !state.done ? [{ text: speed }] : []), { tag: pct, color: "blue" }], + detail: { + stats: [ + { label: "done", value: pct, color: "blue" }, + { label: "downloaded", value: bytes(t.completed) }, + { label: "size", value: t.total ? bytes(t.total) : "…" }, + ], + metadata: [ + meta.progress("Progress", t.ratio, t.total ? `${bytes(t.completed)} of ${bytes(t.total)}` : pct), + meta.text("Step", statusLabel(state.status), { icon: Icons.inProgress }), + ...(speed ? [meta.text("Speed", speed, { icon: Icons.bolt })] : []), + meta.separator(), + meta.link("Library", libraryUrl(name).replace(/^https:\/\//, ""), libraryUrl(name)), + ], + markdown: "Leaving this view cancels the download; pulling again resumes it.", + }, + actions: [ + copy(`ollama pull ${name}`, "Copy Pull Command", { icon: Icons.terminal }), + open(libraryUrl(name), "Open in Ollama Library"), + ], + }; + const layers: Item[] = state.layers.map((l) => { + const ratio = l.total ? Math.min(1, l.completed / l.total) : 0; + const done = ratio >= 1; + return { + id: l.digest, + title: `Layer ${l.digest.replace(/^sha256:/, "").slice(0, 12)}`, + subtitle: `${bytes(Math.min(l.completed, l.total))} of ${bytes(l.total)}`, + icon: { source: done ? Icons.done : Icons.inProgress, tint: done ? "green" : "blue" }, + section: "Layers", + accessories: [{ text: `${Math.floor(ratio * 100)}%`, color: done ? "green" as const : undefined }], + detail: { metadata: [meta.progress("Layer", ratio), meta.text("Digest", l.digest, { icon: Icons.hash })] }, + actions: [copy(l.digest, "Copy Digest")], + }; + }); + return [overall, ...layers]; +} + +function readyItem(name: string): Item { + return { + id: "overall", + title: `${displayName(name)} is ready`, + subtitle: `ollama run ${name}`, + icon: { source: Icons.done, tint: "green" }, + section: "Progress", + accessories: [{ tag: "100%", color: "green" }], + detail: { + stats: [{ label: "done", value: "100%", color: "green" }], + metadata: [meta.progress("Progress", 1, "Downloaded and verified")], + }, + actions: [ + copy(`ollama run ${name}`, "Copy ollama run Command", { icon: Icons.terminal }), + action("Done", () => close(`${displayName(name)} is ready`), { icon: Icons.check }), + open(libraryUrl(name), "Open in Ollama Library"), + ], + }; +} + +function failedItem(name: string, error: string): Item { + const missing = /file does not exist|not found|manifest unknown/i.test(error); + return { + id: "failed", + title: missing ? `No model named ${name}` : `Pulling ${name} failed`, + subtitle: error, + icon: { source: Icons.alert, tint: "red" }, + detail: { + markdown: missing + ? `Ollama's library has no **${name}**. Check the name and tag on ollama.com/library.` + : `\`\`\`\n${error}\n\`\`\``, + }, + actions: [ + action("Try Again", () => refresh(), { icon: Icons.refresh }), + open( + missing ? `https://ollama.com/search?q=${encodeURIComponent(name.split(":")[0])}` : libraryUrl(name), + "Search the Library", + ), + copy(error, "Copy Error", { section: "Copy" }), + ], + }; +} diff --git a/extensions/package-registries/api.ts b/extensions/package-registries/api.ts new file mode 100644 index 0000000..4b7d986 --- /dev/null +++ b/extensions/package-registries/api.ts @@ -0,0 +1,112 @@ +// One search per registry, over their public JSON APIs. + +import { + escapeModule, + looksLikeModule, + mapCrates, + mapGems, + mapGo, + mapHex, + mapMaven, + mapNuget, + mapPackagist, + mapPub, + mapPypi, + mavenQuery, + type Package, + packagistLatest, + pubNames, + pypiCandidates, + type RegistryId, +} from "./lib.ts"; + +// crates.io refuses requests without a User-Agent saying who is calling. +const USER_AGENT = "Backslash launcher package search (https://github.com/backslash-app/plugins)"; + +export class NotFound extends Error {} + +async function get(url: string, signal: AbortSignal, accept = "application/json"): Promise<any> { + const res = await fetch(url, { signal, headers: { "User-Agent": USER_AGENT, Accept: accept } }); + if (res.status === 404) throw new NotFound(url); + if (res.status === 429) throw new Error(`${new URL(url).host} is rate limiting: wait a little`); + if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`); + return await res.json(); +} + +const optional = (p: Promise<any>) => p.catch((e) => (e instanceof NotFound ? undefined : Promise.reject(e))); +const q = encodeURIComponent; + +const SEARCHES: Record<RegistryId, (query: string, signal: AbortSignal) => Promise<Package[]>> = { + async crates(query, signal) { + return mapCrates(await get(`https://crates.io/api/v1/crates?q=${q(query)}&per_page=20`, signal)); + }, + + async pypi(query, signal) { + // No search API: exact JSON lookups for the likely names. + const names = pypiCandidates(query); + const found = await Promise.all(names.map((n) => optional(get(`https://pypi.org/pypi/${n}/json`, signal)))); + return found.filter(Boolean).map(mapPypi); + }, + + async go(query, signal) { + const path = query.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""); + if (!looksLikeModule(path)) return []; + // Also the newer major versions, which are separate modules (/v2, /v3…). + const paths = /\/v\d+$/.test(path) ? [path] : [path, `${path}/v2`, `${path}/v3`, `${path}/v4`, `${path}/v5`]; + const latest = await Promise.all( + paths.map((p) => + optional(get(`https://proxy.golang.org/${escapeModule(p)}/@latest`, signal)).catch(() => undefined) + ), + ); + return paths.flatMap((p, i) => latest[i] ? [mapGo(p, latest[i])] : []).reverse(); + }, + + async gems(query, signal) { + return mapGems(await get(`https://rubygems.org/api/v1/search.json?query=${q(query)}`, signal)).slice(0, 20); + }, + + async packagist(query, signal) { + const json = await get(`https://packagist.org/search.json?q=${q(query)}&per_page=15`, signal); + // Versions and licenses come from the static metadata files, top results only. + const names: string[] = (json.results ?? []).slice(0, 8).map((r: any) => r.name); + const files = await Promise.all( + names.map((n) => get(`https://repo.packagist.org/p2/${n}.json`, signal).catch(() => undefined)), + ); + const latest = Object.fromEntries(names.map((n, i) => [n, packagistLatest(files[i], n)])); + return mapPackagist(json, latest); + }, + + async maven(query, signal) { + const json = await get( + `https://search.maven.org/solrsearch/select?q=${q(mavenQuery(query))}&rows=20&wt=json`, + signal, + ); + return mapMaven(json); + }, + + async hex(query, signal) { + return mapHex(await get(`https://hex.pm/api/packages?search=${q(query)}&sort=recent_downloads`, signal)).slice( + 0, + 20, + ); + }, + + async pub(query, signal) { + const names = pubNames(await get(`https://pub.dev/api/search?q=${q(query)}`, signal)).slice(0, 8); + const details = await Promise.all(names.map((n) => + Promise.all([ + get(`https://pub.dev/api/packages/${n}`, signal, "application/vnd.pub.v2+json"), + get(`https://pub.dev/api/packages/${n}/score`, signal).catch(() => undefined), + ]).catch(() => undefined) + )); + return details.filter((d): d is [any, any] => !!d).map(([pkg, score]) => mapPub(pkg, score)); + }, + + async nuget(query, signal) { + return mapNuget( + await get(`https://azuresearch-usnc.nuget.org/query?q=${q(query)}&take=20&semVerLevel=2.0.0`, signal), + ); + }, +}; + +export const searchRegistry = (id: RegistryId, query: string, signal: AbortSignal) => SEARCHES[id](query, signal); diff --git a/extensions/package-registries/backslash.toml b/extensions/package-registries/backslash.toml new file mode 100644 index 0000000..79a5be5 --- /dev/null +++ b/extensions/package-registries/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "package-registries" +title = "Package Registries" +description = "Search crates.io, PyPI, Go modules, RubyGems, Packagist, Maven Central, Hex, pub.dev and NuGet" +version = "1.0.0" +author = "Backslash" +icon = "bs-package" +license = "MIT" + +[permissions] +# Each registry's public JSON API. Packagist versions and licenses come from +# repo.packagist.org; Go modules from the module proxy (pkg.go.dev has no API). +net = [ + "crates.io", + "pypi.org", + "proxy.golang.org", + "rubygems.org", + "packagist.org", + "repo.packagist.org", + "search.maven.org", + "hex.pm", + "pub.dev", + "azuresearch-usnc.nuget.org", +] + +[[commands]] +name = "search" +title = "Search Package Registries" +description = "Version, downloads, license and install commands; pick the registry with Tab or type cargo, pip, go…" +icon = "bs-package" +keywords = [ + "package", + "library", + "crate", + "cargo", + "pypi", + "pip", + "go", + "gem", + "composer", + "maven", + "gradle", + "hex", + "pub", + "flutter", + "nuget", + "dependency", + "paquet", + "bibliothèque", + "dépendance", +] diff --git a/extensions/package-registries/commands/search.ts b/extensions/package-registries/commands/search.ts new file mode 100644 index 0000000..46bfd35 --- /dev/null +++ b/extensions/package-registries/commands/search.ts @@ -0,0 +1,149 @@ +import { type Action, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { searchRegistry } from "../api.ts"; +import { + compact, + type Package, + parseQuery, + rank, + REGISTRIES, + registry, + type RegistryId, + relativeDate, +} from "../lib.ts"; + +function packageItem(p: Package): Item { + const r = registry(p.registry)!; + const updated = relativeDate(p.updated); + const [mainTitle, mainCommand] = p.install[0]; + const links: Action[] = [ + ...(p.repository ? [open(p.repository, "Open Repository", { icon: Icons.repository, shortcut: "<Ctrl>r" })] : []), + ...(p.docs ? [open(p.docs, "Open Documentation", { icon: Icons.book, shortcut: "<Ctrl>d" })] : []), + ...(p.homepage && p.homepage !== p.repository ? [open(p.homepage, "Open Homepage", { icon: Icons.home })] : []), + ]; + return { + id: `${p.registry}:${p.name}`, + title: p.name, + subtitle: p.description, + icon: { source: p.deprecated ? Icons.alert : Icons.package, tint: p.deprecated ? "red" : r.tint }, + keywords: p.keywords, + accessories: [ + ...(p.deprecated ? [{ tag: "Deprecated", color: "red" as const, tooltip: p.deprecated }] : []), + ...(p.downloads !== undefined + ? [{ icon: Icons.download, text: compact(p.downloads), tooltip: `Downloads, ${p.downloadsPeriod}` }] + : []), + ...(p.version ? [{ tag: p.version, color: r.tint }] : []), + ], + detail: { + stats: [ + ...(p.version ? [{ label: "Version", value: p.version }] : []), + ...(p.downloads !== undefined + ? [{ label: `Downloads · ${p.downloadsPeriod}`, value: compact(p.downloads) }] + : []), + ...(updated ? [{ label: "Released", value: updated }] : []), + ], + markdown: [ + p.description ?? "", + ...(p.deprecated ? ["", `> ${p.deprecated}`] : []), + "", + `**${mainTitle}**`, + "", + "```", + mainCommand, + "```", + ].join("\n").trim(), + metadata: [ + meta.text("Registry", `${r.title} · ${r.ecosystem}`, { icon: Icons.package }), + ...(p.license ? [meta.text("License", p.license, { icon: Icons.shield })] : []), + ...(p.updated + ? [ + meta.text("Updated", new Date(p.updated).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.calendar, + }), + ] + : []), + ...(p.facts ?? []).map(([label, value]) => meta.text(label, value)), + ...(p.keywords?.length ? [meta.tags("Keywords", p.keywords.slice(0, 8))] : []), + meta.separator(), + meta.link(r.title, p.page.replace(/^https:\/\/(www\.)?/, ""), p.page), + ...(p.repository + ? [meta.link("Repository", p.repository.replace(/^https:\/\/(www\.)?/, ""), p.repository)] + : []), + ...(p.docs ? [meta.link("Docs", p.docs.replace(/^https:\/\/(www\.)?/, ""), p.docs)] : []), + ], + }, + actions: [ + copy(mainCommand, `Copy ${mainTitle} Command`), + open(p.page, `Open on ${r.title}`, { shortcut: "<Ctrl>o" }), + ...p.install.slice(1).map(([title, text], i) => + copy(text, `Copy ${title}`, { + icon: Icons.code, + section: "Install", + ...(i === 0 ? { shortcut: "<Ctrl><Shift>c" } : {}), + }) + ), + ...links.map((a) => ({ ...a, section: "Links" })), + copy(p.name, "Copy Name", { section: "Copy" }), + ...(p.version ? [copy(p.version, "Copy Version", { section: "Copy" })] : []), + ], + }; +} + +function hint(id: RegistryId): Item[] { + return REGISTRIES.map((r) => ({ + id: `hint:${r.id}`, + title: `${r.aliases[0]} ${r.example}`, + subtitle: `${r.title} · ${r.ecosystem}`, + icon: { source: Icons.package, tint: r.tint }, + section: r.id === id ? "Selected Registry" : "Type a Registry Name First", + accessories: r.id === id ? [{ tag: "Selected", color: r.tint }] : [{ text: r.aliases.slice(1, 4).join(", ") }], + detail: r.note ? { markdown: r.note } : undefined, + actions: [open(r.webSearch(""), `Open ${r.title}`)], + })).sort((a, b) => Number(b.id === `hint:${id}`) - Number(a.id === `hint:${id}`)); +} + +let last = ""; + +export default list({ + placeholder: "Search packages… (prefix with cargo, pip, go, gem, composer, maven, hex, pub, nuget)", + filter: { options: REGISTRIES.map((r) => ({ value: r.id, title: r.title })), default: "crates" }, + empty: { + title: "No package found", + description: "Check the spelling, or try another registry.", + icon: Icons.package, + }, + status: () => last, + async search(input, { filter, signal }) { + const { registry: id, query } = parseQuery(input, (filter as RegistryId) ?? "crates"); + const r = registry(id)!; + if (!query) { + last = `${r.title} · ${r.ecosystem}`; + return hint(id); + } + let found: Package[]; + try { + found = rank(await searchRegistry(id, query, signal), query); + } catch (e) { + if (signal.aborted) throw e; + last = `${r.title} is unreachable`; + return [{ + id: "error", + title: `${r.title} can't be reached`, + subtitle: e instanceof Error ? e.message : String(e), + icon: { source: Icons.wifiOff, tint: "red" }, + actions: [open(r.webSearch(query), `Search on ${r.title}`)], + }]; + } + last = `${found.length} result${found.length === 1 ? "" : "s"} on ${r.title}`; + const items = found.map(packageItem); + // What the API can't find, the website might. + items.push({ + id: "web", + title: `Search “${query}” on ${r.title}`, + subtitle: r.note ?? "Open the registry's own search", + icon: { source: Icons.search, tint: "gray" }, + section: found.length ? "More" : undefined, + actions: [open(r.webSearch(query), `Search on ${r.title}`)], + }); + return items; + }, +}); diff --git a/extensions/package-registries/lib.ts b/extensions/package-registries/lib.ts new file mode 100644 index 0000000..8797254 --- /dev/null +++ b/extensions/package-registries/lib.ts @@ -0,0 +1,492 @@ +// Package registries: query parsing and mapping each API's JSON to one shape. Pure, tested in lib_test.ts. + +export type RegistryId = "crates" | "pypi" | "go" | "gems" | "packagist" | "maven" | "hex" | "pub" | "nuget"; + +export type Tint = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "teal" | "pink" | "gray"; + +export interface Registry { + id: RegistryId; + title: string; + ecosystem: string; + tint: Tint; + /** Words that pick this registry when typed first: `cargo serde`. */ + aliases: string[]; + example: string; + /** The registry's own web search, for what its API can't do. */ + webSearch: (q: string) => string; + /** Explains a limitation of the API, shown with the results. */ + note?: string; +} + +export const REGISTRIES: Registry[] = [ + { + id: "crates", + title: "crates.io", + ecosystem: "Rust", + tint: "orange", + aliases: ["cargo", "crates", "crate", "rust"], + example: "serde", + webSearch: (q) => `https://crates.io/search?q=${encodeURIComponent(q)}`, + }, + { + id: "pypi", + title: "PyPI", + ecosystem: "Python", + tint: "blue", + aliases: ["pip", "pypi", "python", "py", "uv"], + example: "requests", + webSearch: (q) => `https://pypi.org/search/?q=${encodeURIComponent(q)}`, + note: + "PyPI has no search API: names are looked up exactly (with python- and py prefixes). Search the site for more.", + }, + { + id: "go", + title: "Go modules", + ecosystem: "Go", + tint: "teal", + aliases: ["go", "golang"], + example: "github.com/go-chi/chi/v5", + webSearch: (q) => `https://pkg.go.dev/search?q=${encodeURIComponent(q)}`, + note: "pkg.go.dev has no search API: type a module path (github.com/…) to look it up on the Go module proxy.", + }, + { + id: "gems", + title: "RubyGems", + ecosystem: "Ruby", + tint: "red", + aliases: ["gem", "gems", "ruby", "rubygems", "bundle"], + example: "rails", + webSearch: (q) => `https://rubygems.org/search?query=${encodeURIComponent(q)}`, + }, + { + id: "packagist", + title: "Packagist", + ecosystem: "PHP", + tint: "purple", + aliases: ["composer", "packagist", "php"], + example: "laravel", + webSearch: (q) => `https://packagist.org/?query=${encodeURIComponent(q)}`, + }, + { + id: "maven", + title: "Maven Central", + ecosystem: "Java, Kotlin", + tint: "pink", + aliases: ["maven", "mvn", "java", "kotlin", "gradle"], + example: "guava", + webSearch: (q) => `https://central.sonatype.com/search?q=${encodeURIComponent(q)}`, + }, + { + id: "hex", + title: "Hex", + ecosystem: "Elixir, Erlang", + tint: "purple", + aliases: ["hex", "elixir", "erlang", "mix"], + example: "phoenix", + webSearch: (q) => `https://hex.pm/packages?search=${encodeURIComponent(q)}`, + }, + { + id: "pub", + title: "pub.dev", + ecosystem: "Dart, Flutter", + tint: "teal", + aliases: ["pub", "dart", "flutter"], + example: "http", + webSearch: (q) => `https://pub.dev/packages?q=${encodeURIComponent(q)}`, + }, + { + id: "nuget", + title: "NuGet", + ecosystem: ".NET", + tint: "blue", + aliases: ["nuget", "dotnet", "csharp", "c#"], + example: "Newtonsoft.Json", + webSearch: (q) => `https://www.nuget.org/packages?q=${encodeURIComponent(q)}`, + }, +]; + +export const registry = (id: string): Registry | undefined => REGISTRIES.find((r) => r.id === id); + +/** `cargo serde` → crates.io and "serde"; anything else uses `fallback`. */ +export function parseQuery(input: string, fallback: RegistryId): { registry: RegistryId; query: string } { + const m = input.trim().match(/^(\S+)\s+([\s\S]+)$/); + if (m) { + const hit = REGISTRIES.find((r) => r.aliases.includes(m[1].toLowerCase())); + if (hit) return { registry: hit.id, query: m[2].trim() }; + } + return { registry: fallback, query: input.trim() }; +} + +export interface Package { + registry: RegistryId; + name: string; + version?: string; + description?: string; + license?: string; + downloads?: number; + /** "all time", "last 90 days"… */ + downloadsPeriod?: string; + /** ISO date of the latest release or update. */ + updated?: string; + page: string; + homepage?: string; + repository?: string; + docs?: string; + keywords?: string[]; + /** [title, text]: the first is the main install command. */ + install: [string, string][]; + /** More [label, value] lines for the side panel. */ + facts?: [string, string][]; + /** The registry says it's the exact name typed. */ + exact?: boolean; + deprecated?: string; +} + +const clean = (s: unknown): string | undefined => { + const t = typeof s === "string" ? s.replace(/\s+/g, " ").trim() : ""; + return t || undefined; +}; + +const url = (s: unknown): string | undefined => { + const t = clean(s); + return t && /^https?:\/\//.test(t) ? t.replace(/\.git$/, "").replace(/^git\+/, "") : undefined; +}; + +// --- crates.io ----------------------------------------------------------------- + +export function mapCrates(json: any): Package[] { + return (json?.crates ?? []).map((c: any): Package => { + const version = c.max_stable_version ?? c.default_version ?? c.max_version; + return { + registry: "crates", + name: c.name, + version, + description: clean(c.description), + downloads: c.downloads, + downloadsPeriod: "all time", + updated: c.updated_at, + page: `https://crates.io/crates/${c.name}`, + homepage: url(c.homepage), + repository: url(c.repository), + docs: url(c.documentation) ?? `https://docs.rs/${c.name}`, + install: [ + ["cargo add", `cargo add ${c.name}`], + ["Cargo.toml", `${c.name} = "${version}"`], + ], + facts: [ + ...(c.recent_downloads + ? [["Recent downloads", `${compact(c.recent_downloads)} in 90 days`] as [string, string]] + : []), + ...(c.num_versions ? [["Versions", String(c.num_versions)] as [string, string]] : []), + ], + exact: c.exact_match === true, + deprecated: c.yanked ? "Yanked" : undefined, + }; + }); +} + +// --- PyPI ------------------------------------------------------------------------ + +/** PEP 503 normalization: `Foo_Bar.baz` → `foo-bar-baz`. */ +export const normalizePython = (name: string) => name.trim().toLowerCase().replace(/[-_.]+/g, "-"); + +/** Names worth an exact lookup for what the user typed. */ +export function pypiCandidates(query: string): string[] { + const base = normalizePython(query.replace(/\s+/g, "-")); + if (!/^[a-z0-9][a-z0-9-]*$/.test(base)) return []; + const names = [base, `python-${base}`, `${base}-python`, `py${base}`]; + return [...new Set(names.filter((n) => n !== "py" && !/^python-python/.test(n)))]; +} + +export function mapPypi(json: any): Package { + const i = json.info; + const urls: Record<string, string> = i.project_urls ?? {}; + const find = (re: RegExp) => Object.entries(urls).find(([k]) => re.test(k))?.[1]; + const uploaded = (json.urls ?? []).map((u: any) => u.upload_time_iso_8601).filter(Boolean).sort().pop(); + const license = clean(i.license_expression) ?? + (clean(i.license) && i.license.length < 40 ? clean(i.license) : undefined) ?? + (i.classifiers ?? []).find((c: string) => c.startsWith("License :: OSI Approved :: "))?.replace( + "License :: OSI Approved :: ", + "", + ); + const python = clean(i.requires_python); + return { + registry: "pypi", + name: i.name, + version: i.version, + description: clean(i.summary), + license, + updated: uploaded, + page: i.package_url ?? `https://pypi.org/project/${i.name}/`, + homepage: url(i.home_page) ?? url(find(/^home/i)), + repository: url(find(/source|repo|github|code/i)), + docs: url(i.docs_url) ?? url(find(/doc/i)), + keywords: clean(i.keywords)?.split(/[,\s]+/).filter(Boolean).slice(0, 8), + install: [ + ["pip install", `pip install ${i.name}`], + ["uv add", `uv add ${i.name}`], + ["poetry add", `poetry add ${i.name}`], + ["requirements.txt", `${i.name}==${i.version}`], + ], + facts: [ + ...(python ? [["Python", python] as [string, string]] : []), + ...(clean(i.author) ? [["Author", clean(i.author)!] as [string, string]] : []), + ], + exact: true, + deprecated: i.yanked ? `Yanked${i.yanked_reason ? `: ${i.yanked_reason}` : ""}` : undefined, + }; +} + +// --- Go -------------------------------------------------------------------------- + +/** The module proxy's case encoding: `github.com/BurntSushi/toml` → `github.com/!burnt!sushi/toml`. */ +export const escapeModule = (path: string) => path.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`); + +export const looksLikeModule = (q: string) => /^[a-z0-9.-]+\.[a-z]{2,}(\/[\w.~-]+)*$/i.test(q.trim()); + +export function mapGo(module: string, latest: any): Package { + const repo = url(latest?.Origin?.URL); + return { + registry: "go", + name: module, + version: latest?.Version, + updated: latest?.Time, + page: `https://pkg.go.dev/${module}`, + repository: repo, + docs: `https://pkg.go.dev/${module}`, + install: [ + ["go get", `go get ${module}@${latest?.Version ?? "latest"}`], + ["import", `import "${module}"`], + ], + facts: latest?.Origin?.Hash ? [["Commit", String(latest.Origin.Hash).slice(0, 12)]] : [], + exact: true, + }; +} + +// --- RubyGems -------------------------------------------------------------------- + +export function mapGems(json: any): Package[] { + return (Array.isArray(json) ? json : []).map((g: any): Package => ({ + registry: "gems", + name: g.name, + version: g.version, + description: clean(g.info), + license: (g.licenses ?? []).join(", ") || undefined, + downloads: g.downloads, + downloadsPeriod: "all time", + page: g.project_uri ?? `https://rubygems.org/gems/${g.name}`, + homepage: url(g.homepage_uri), + repository: url(g.source_code_uri), + docs: url(g.documentation_uri), + install: [ + ["gem install", `gem install ${g.name}`], + ["bundle add", `bundle add ${g.name}`], + ["Gemfile", `gem "${g.name}", "~> ${String(g.version ?? "").split(".").slice(0, 2).join(".")}"`], + ], + facts: [ + ...(clean(g.authors) ? [["Authors", clean(g.authors)!] as [string, string]] : []), + ...(g.version_downloads + ? [["This version", `${compact(g.version_downloads)} downloads`] as [string, string]] + : []), + ], + })); +} + +// --- Packagist ------------------------------------------------------------------- + +/** The newest stable version of a `repo.packagist.org/p2/<name>.json` file. */ +export function packagistLatest(p2: any, name: string): any | undefined { + const versions: any[] = p2?.packages?.[name] ?? []; + return versions.find((v) => !/dev|alpha|beta|rc/i.test(v.version)) ?? versions[0]; +} + +export function mapPackagist(json: any, latest: Record<string, any> = {}): Package[] { + return (json?.results ?? []).filter((r: any) => !r.virtual).map((r: any): Package => { + const v = latest[r.name]; + const version = v?.version?.replace(/^v/, ""); + return { + registry: "packagist", + name: r.name, + version, + description: clean(r.description), + license: v?.license?.join(", ") || undefined, + downloads: r.downloads, + downloadsPeriod: "all time", + updated: v?.time, + page: r.url ?? `https://packagist.org/packages/${r.name}`, + homepage: url(v?.homepage), + repository: url(r.repository), + install: [ + ["composer require", `composer require ${r.name}`], + ...(version + ? [["composer.json", `"${r.name}": "^${version.split(".").slice(0, 2).join(".")}"`] as [string, string]] + : []), + ], + facts: r.favers ? [["Stars", compact(r.favers)]] : [], + deprecated: r.abandoned ? `Abandoned${typeof r.abandoned === "string" ? `: use ${r.abandoned}` : ""}` : undefined, + }; + }); +} + +// --- Maven Central --------------------------------------------------------------- + +/** `com.google.guava:guava` searches that exact artifact; words search everything. */ +export function mavenQuery(q: string): string { + const m = q.trim().match(/^([\w.-]+):([\w.-]+)$/); + return m ? `g:"${m[1]}" AND a:"${m[2]}"` : q.trim(); +} + +export function mapMaven(json: any): Package[] { + return (json?.response?.docs ?? []).map((d: any): Package => { + const version = d.latestVersion ?? d.v; + const coords = `${d.g}:${d.a}`; + return { + registry: "maven", + name: coords, + version, + updated: d.timestamp ? new Date(d.timestamp).toISOString() : undefined, + page: `https://central.sonatype.com/artifact/${d.g}/${d.a}`, + install: [ + ["Gradle (Kotlin)", `implementation("${coords}:${version}")`], + ["Gradle (Groovy)", `implementation '${coords}:${version}'`], + [ + "Maven", + `<dependency>\n <groupId>${d.g}</groupId>\n <artifactId>${d.a}</artifactId>\n <version>${version}</version>\n</dependency>`, + ], + ], + facts: [ + ["Group", d.g], + ["Artifact", d.a], + ...(d.p ? [["Packaging", d.p] as [string, string]] : []), + ...(d.versionCount ? [["Versions", String(d.versionCount)] as [string, string]] : []), + ], + }; + }); +} + +// --- Hex ------------------------------------------------------------------------- + +export function mapHex(json: any): Package[] { + return (Array.isArray(json) ? json : []).map((p: any): Package => { + const version = p.latest_stable_version ?? p.latest_version; + const links: Record<string, string> = p.meta?.links ?? {}; + const repo = Object.entries(links).find(([k]) => /github|gitlab|source|codeberg/i.test(k))?.[1]; + return { + registry: "hex", + name: p.name, + version, + description: clean(p.meta?.description), + license: (p.meta?.licenses ?? []).join(", ") || undefined, + downloads: p.downloads?.all, + downloadsPeriod: "all time", + updated: p.updated_at, + page: p.html_url ?? `https://hex.pm/packages/${p.name}`, + repository: url(repo), + docs: url(p.docs_html_url), + install: [ + ["mix.exs", p.configs?.["mix.exs"] ?? `{:${p.name}, "~> ${version}"}`], + ...(p.configs?.["rebar.config"] ? [["rebar.config", p.configs["rebar.config"]] as [string, string]] : []), + ], + facts: p.downloads?.recent ? [["Recent downloads", `${compact(p.downloads.recent)} in 90 days`]] : [], + deprecated: p.retirements && Object.keys(p.retirements).includes(version) ? "This version is retired" : undefined, + }; + }); +} + +// --- pub.dev --------------------------------------------------------------------- + +export const pubNames = (json: any): string[] => (json?.packages ?? []).map((p: any) => p.package); + +export function mapPub(pkg: any, score?: any): Package { + const spec = pkg.latest?.pubspec ?? {}; + const license = (score?.tags ?? []).find((t: string) => t.startsWith("license:") && !/fsf|osi/.test(t)) + ?.slice(8).toUpperCase(); + const flutter = (score?.tags ?? []).includes("sdk:flutter") && !(score?.tags ?? []).includes("sdk:dart"); + return { + registry: "pub", + name: pkg.name, + version: pkg.latest?.version, + description: clean(spec.description), + license, + downloads: score?.downloadCount30Days, + downloadsPeriod: "last 30 days", + updated: pkg.latest?.published, + page: `https://pub.dev/packages/${pkg.name}`, + homepage: url(spec.homepage), + repository: url(spec.repository), + docs: `https://pub.dev/documentation/${pkg.name}/latest/`, + keywords: spec.topics, + install: [ + [flutter ? "flutter pub add" : "dart pub add", `${flutter ? "flutter" : "dart"} pub add ${pkg.name}`], + ...(flutter ? [] : [["flutter pub add", `flutter pub add ${pkg.name}`] as [string, string]]), + ["pubspec.yaml", `${pkg.name}: ^${pkg.latest?.version}`], + ], + facts: [ + ...(score?.likeCount !== undefined ? [["Likes", compact(score.likeCount)] as [string, string]] : []), + ...(score?.grantedPoints !== undefined + ? [["Pub points", `${score.grantedPoints} / ${score.maxPoints}`] as [string, string]] + : []), + ...((score?.tags ?? []).find((t: string) => t.startsWith("publisher:")) + ? [["Publisher", score.tags.find((t: string) => t.startsWith("publisher:")).slice(10)] as [string, string]] + : []), + ], + deprecated: pkg.isDiscontinued ? `Discontinued${pkg.replacedBy ? `: use ${pkg.replacedBy}` : ""}` : undefined, + }; +} + +// --- NuGet ----------------------------------------------------------------------- + +export function mapNuget(json: any): Package[] { + return (json?.data ?? []).map((p: any): Package => ({ + registry: "nuget", + name: p.id, + version: p.version, + description: clean(p.description)?.split("\n")[0], + license: /licenses\.nuget\.org\/(.+)$/.exec(p.licenseUrl ?? "")?.[1], + downloads: p.totalDownloads, + downloadsPeriod: "all time", + page: `https://www.nuget.org/packages/${p.id}`, + homepage: url(p.projectUrl), + keywords: (p.tags ?? []).slice(0, 8), + install: [ + ["dotnet add package", `dotnet add package ${p.id}`], + ["PackageReference", `<PackageReference Include="${p.id}" Version="${p.version}" />`], + ["Package Manager", `Install-Package ${p.id}`], + ], + facts: [ + ...((p.owners ?? []).length ? [["Owners", [].concat(p.owners).join(", ")] as [string, string]] : []), + ...(p.verified ? [["Prefix", "Verified owner"] as [string, string]] : []), + ], + deprecated: p.deprecation ? `Deprecated${p.deprecation.message ? `: ${p.deprecation.message}` : ""}` : undefined, + })); +} + +// --- Formatting ------------------------------------------------------------------ + +/** 1234 → "1.2k", 1_602_431_895 → "1.6B". */ +export function compact(n: number): string { + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`; + return String(n); +} + +export function relativeDate(iso: string | undefined, now = new Date()): string | undefined { + if (!iso) return undefined; + const d = new Date(iso); + if (isNaN(d.getTime())) return undefined; + const days = Math.floor((now.getTime() - d.getTime()) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 30) return `${days} days ago`; + if (days < 365) return `${Math.round(days / 30)} month${Math.round(days / 30) > 1 ? "s" : ""} ago`; + const years = Math.round(days / 365); + return `${years} year${years > 1 ? "s" : ""} ago`; +} + +/** Exact names first, then the registry's own order. */ +export function rank(packages: Package[], query: string): Package[] { + const q = query.trim().toLowerCase(); + return packages.map((p, i) => ({ p, i, exact: p.exact || p.name.toLowerCase() === q })) + .sort((a, b) => Number(b.exact) - Number(a.exact) || a.i - b.i).map((x) => x.p); +} diff --git a/extensions/package-registries/lib_test.ts b/extensions/package-registries/lib_test.ts new file mode 100644 index 0000000..eea69fb --- /dev/null +++ b/extensions/package-registries/lib_test.ts @@ -0,0 +1,436 @@ +import { assertEquals } from "@std/assert"; +import { + compact, + escapeModule, + looksLikeModule, + mapCrates, + mapGems, + mapGo, + mapHex, + mapMaven, + mapNuget, + mapPackagist, + mapPub, + mapPypi, + mavenQuery, + normalizePython, + packagistLatest, + parseQuery, + pubNames, + pypiCandidates, + rank, + relativeDate, +} from "./lib.ts"; + +// Trimmed real responses (October 2026); Maven Central and NuGet follow their documented shapes. +const CRATES = { + "crates": [{ + "id": "serde", + "name": "serde", + "updated_at": "2026-07-18T23:05:13.266456Z", + "versions": null, + "keywords": null, + "categories": null, + "badges": [], + "created_at": "2014-12-05T20:20:39.487502Z", + "downloads": 1602431895, + "recent_downloads": 386912775, + "default_version": "1.0.229", + "num_versions": 316, + "yanked": false, + "max_version": "1.0.229", + "newest_version": "1.0.229", + "max_stable_version": "1.0.229", + "description": "A generic serialization/deserialization framework", + "homepage": "https://serde.rs", + "documentation": "https://docs.rs/serde", + "repository": "https://github.com/serde-rs/serde", + "exact_match": true, + "trustpub_only": false, + }], + "meta": { "total": 24467 }, +}; +const PYPI = { + "info": { + "name": "requests", + "version": "2.34.2", + "summary": "Python HTTP for Humans.", + "license": "Apache-2.0", + "license_expression": null, + "home_page": null, + "project_urls": { "Documentation": "https://requests.readthedocs.io", "Source": "https://github.com/psf/requests" }, + "requires_python": ">=3.10", + "author": null, + "keywords": null, + "package_url": "https://pypi.org/project/requests/", + "docs_url": null, + "yanked": false, + "yanked_reason": null, + "classifiers": ["License :: OSI Approved :: Apache Software License"], + }, + "urls": [{ "upload_time_iso_8601": "2026-05-14T19:25:26.443000Z" }, { + "upload_time_iso_8601": "2026-05-14T19:25:27.735762Z", + }], +}; +const GO = { + "Version": "v5.5.0", + "Time": "2026-10-14T13:20:04Z", + "Origin": { + "VCS": "git", + "URL": "https://github.com/go-chi/chi", + "Hash": "3a33ba578e0dd93444e0ff386cc26e665d92e31e", + "Ref": "refs/tags/v5.5.0", + }, +}; +const GEMS = [{ + "documentation_uri": "https://api.rubyonrails.org/v8.1.4/", + "homepage_uri": "https://rubyonrails.org", + "funding_uri": null, + "bug_tracker_uri": "https://github.com/rails/rails/issues", + "project_uri": "https://rubygems.org/gems/rails", + "version": "8.1.4", + "platform": "ruby", + "changelog_uri": "https://github.com/rails/rails/releases/tag/v8.1.4", + "source_code_uri": "https://github.com/rails/rails/tree/v8.1.4", + "licenses": ["MIT"], + "gem_uri": "https://rubygems.org/gems/rails-8.1.4.gem", + "downloads": 807907647, + "mailing_list_uri": "https://discuss.rubyonrails.org/c/rubyonrails-talk", + "name": "rails", + "wiki_uri": null, + "version_downloads": 3197458, + "info": + "Ruby on Rails is a full-stack web framework optimized for programmer happiness and sustainable productivity. It encourages beautiful code by favoring convention over configuration.", + "authors": "David Heinemeier Hansson", +}]; +const PACKAGIST = { + "results": [{ + "name": "laravel/framework", + "description": "The Laravel Framework.", + "url": "https://packagist.org/packages/laravel/framework", + "repository": "https://github.com/laravel/framework", + "downloads": 606007207, + "favers": 35474, + }, { + "name": "spatie/laravel-permission", + "description": "Permission handling for Laravel 12 and up", + "url": "https://packagist.org/packages/spatie/laravel-permission", + "repository": "https://github.com/spatie/laravel-permission", + "downloads": 128373642, + "favers": 13145, + }], + "total": 99085, + "next": "https://packagist.org/search.json?q=laravel&page=2&per_page=2", +}; +const P2 = { + "packages": { + "laravel/framework": [{ "version": "v13.40.0-beta1" }, { + "version": "v13.39.0", + "license": ["MIT"], + "time": "2026-10-27T14:04:39+00:00", + "homepage": "https://laravel.com", + }], + }, +}; +const HEX = [{ + "meta": { + "links": { + "Changelog": "https://phoenix.hexdocs.pm/changelog.html", + "GitHub": "https://github.com/phoenixframework/phoenix", + }, + "description": "Peace of mind from prototype to production", + "licenses": ["MIT"], + "maintainers": [], + }, + "name": "phoenix", + "url": "https://hex.pm/api/packages/phoenix", + "inserted_at": "2014-04-21T22:38:32.000000Z", + "updated_at": "2026-10-20T10:33:08.398912Z", + "repository": "hexpm", + "html_url": "https://hex.pm/packages/phoenix", + "latest_version": "1.8.16", + "downloads": { "all": 158653497, "day": 97873, "recent": 5919337, "week": 615197 }, + "docs_html_url": "https://phoenix.hexdocs.pm/", + "retirements": {}, + "configs": { + "erlang.mk": "dep_phoenix = hex 1.8.16", + "mix.exs": '{:phoenix, "~> 1.8"}', + "rebar.config": '{phoenix, "1.8.16"}', + }, + "latest_stable_version": "1.8.16", +}]; +const PUB = { + "name": "http", + "latest": { + "version": "1.6.0", + "published": "2025-11-10T18:27:56.434747Z", + "pubspec": { + "name": "http", + "description": "A composable, multi-platform, Future-based API for HTTP requests.", + "repository": "https://github.com/dart-lang/http/tree/master/pkgs/http", + "topics": ["http", "network", "protocols"], + }, + }, +}; +const PUB_SCORE = { + "grantedPoints": 160, + "maxPoints": 160, + "likeCount": 8480, + "downloadCount30Days": 13858269, + "tags": [ + "publisher:dart.dev", + "has:topic", + "sdk:dart", + "sdk:flutter", + "platform:android", + "platform:ios", + "platform:windows", + "platform:linux", + "platform:macos", + "platform:web", + "runtime:native-aot", + "runtime:native-jit", + "runtime:web", + "is:null-safe", + "is:wasm-ready", + "is:dart3-compatible", + "license:bsd-3-clause", + "license:fsf-libre", + "license:osi-approved", + "topic:http", + "topic:network", + "topic:protocols", + ], +}; +const MAVEN = { + "responseHeader": { "status": 0 }, + "response": { + "numFound": 2, + "start": 0, + "docs": [{ + "id": "com.google.guava:guava", + "g": "com.google.guava", + "a": "guava", + "latestVersion": "33.5.0-jre", + "repositoryId": "central", + "p": "bundle", + "timestamp": 1758114632000, + "versionCount": 122, + "text": ["com.google.guava", "guava", "-sources.jar", ".jar", ".pom"], + "ec": ["-sources.jar", ".jar", ".pom"], + }, { + "id": "com.google.guava:guava-testlib", + "g": "com.google.guava", + "a": "guava-testlib", + "latestVersion": "33.5.0-jre", + "repositoryId": "central", + "p": "jar", + "timestamp": 1758114631000, + "versionCount": 98, + }], + }, +}; +const NUGET = { + "totalHits": 2, + "data": [{ + "@id": "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json", + "@type": "Package", + "registration": "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json", + "id": "Newtonsoft.Json", + "version": "13.0.4", + "description": "Json.NET is a popular high-performance JSON framework for .NET\nSecond line", + "summary": "", + "title": "Json.NET", + "iconUrl": "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/13.0.4/icon", + "licenseUrl": "https://www.nuget.org/packages/Newtonsoft.Json/13.0.4/license", + "projectUrl": "https://www.newtonsoft.com/json", + "tags": ["json"], + "authors": ["James Newton-King"], + "owners": ["dotnetfoundation", "jamesnk", "newtonsoft"], + "totalDownloads": 7006651044, + "verified": true, + "packageTypes": [{ "name": "Dependency" }], + "versions": [], + }, { + "id": "System.Text.Json", + "version": "9.0.10", + "description": "Provides high-performance and low-allocating types.", + "licenseUrl": "https://licenses.nuget.org/MIT", + "projectUrl": "https://dot.net/", + "tags": [], + "owners": "Microsoft", + "totalDownloads": 4100000000, + "verified": true, + }], +}; + +Deno.test("registry prefixes", () => { + assertEquals(parseQuery("cargo serde", "pypi"), { registry: "crates", query: "serde" }); + assertEquals(parseQuery("pip install-tools", "crates"), { registry: "pypi", query: "install-tools" }); + assertEquals(parseQuery("go github.com/go-chi/chi", "crates"), { registry: "go", query: "github.com/go-chi/chi" }); + assertEquals(parseQuery(" rails ", "gems"), { registry: "gems", query: "rails" }); + assertEquals(parseQuery("serde json", "crates"), { registry: "crates", query: "serde json" }); + assertEquals(parseQuery("nuget", "crates"), { registry: "crates", query: "nuget" }); +}); + +Deno.test("crates.io", () => { + const [serde] = mapCrates(CRATES); + assertEquals([serde.name, serde.version, serde.downloads, serde.exact], ["serde", "1.0.229", 1602431895, true]); + assertEquals([serde.homepage, serde.repository, serde.docs], [ + "https://serde.rs", + "https://github.com/serde-rs/serde", + "https://docs.rs/serde", + ]); + assertEquals(serde.install, [["cargo add", "cargo add serde"], ["Cargo.toml", 'serde = "1.0.229"']]); + assertEquals(serde.facts?.[0], ["Recent downloads", "387M in 90 days"]); + assertEquals(mapCrates({}), []); +}); + +Deno.test("PyPI: exact lookups", () => { + assertEquals(pypiCandidates("Requests"), ["requests", "python-requests", "requests-python", "pyrequests"]); + assertEquals(pypiCandidates("python dateutil")[0], "python-dateutil"); + assertEquals(pypiCandidates("??"), []); + assertEquals(normalizePython("Foo_Bar.baz"), "foo-bar-baz"); + const p = mapPypi(PYPI); + assertEquals([p.name, p.version, p.description, p.license], [ + "requests", + "2.34.2", + "Python HTTP for Humans.", + "Apache-2.0", + ]); + assertEquals([p.repository, p.docs, p.page], [ + "https://github.com/psf/requests", + "https://requests.readthedocs.io", + "https://pypi.org/project/requests/", + ]); + assertEquals(p.install[0], ["pip install", "pip install requests"]); + assertEquals(p.install[3], ["requirements.txt", "requests==2.34.2"]); + assertEquals(p.facts, [["Python", ">=3.10"]]); + // The license falls back to the classifiers. + const old = mapPypi({ info: { ...PYPI.info, license: null, license_expression: null }, urls: [] }); + assertEquals(old.license, "Apache Software License"); +}); + +Deno.test("Go modules", () => { + assertEquals(escapeModule("github.com/BurntSushi/toml"), "github.com/!burnt!sushi/toml"); + assertEquals([ + looksLikeModule("github.com/go-chi/chi/v5"), + looksLikeModule("golang.org/x/net"), + looksLikeModule("chi"), + ], [ + true, + true, + false, + ]); + const m = mapGo("github.com/go-chi/chi/v5", GO); + assertEquals([m.version, m.repository, m.page], [ + "v5.5.0", + "https://github.com/go-chi/chi", + "https://pkg.go.dev/github.com/go-chi/chi/v5", + ]); + assertEquals(m.install, [["go get", "go get github.com/go-chi/chi/v5@v5.5.0"], [ + "import", + 'import "github.com/go-chi/chi/v5"', + ]]); +}); + +Deno.test("RubyGems", () => { + const [rails] = mapGems(GEMS); + assertEquals([rails.name, rails.version, rails.license, rails.downloads], ["rails", "8.1.4", "MIT", 807907647]); + assertEquals(rails.install, [ + ["gem install", "gem install rails"], + ["bundle add", "bundle add rails"], + ["Gemfile", 'gem "rails", "~> 8.1"'], + ]); + assertEquals(rails.repository, "https://github.com/rails/rails/tree/v8.1.4"); +}); + +Deno.test("Packagist with versions from the metadata files", () => { + const latest = { "laravel/framework": packagistLatest(P2, "laravel/framework") }; + const [laravel, spatie] = mapPackagist(PACKAGIST, latest); + assertEquals([laravel.name, laravel.version, laravel.license, laravel.downloads], [ + "laravel/framework", + "13.39.0", + "MIT", + 606007207, + ]); + assertEquals(laravel.install, [["composer require", "composer require laravel/framework"], [ + "composer.json", + '"laravel/framework": "^13.39"', + ]]); + assertEquals([spatie.version, spatie.install.length], [undefined, 1]); + assertEquals(laravel.facts, [["Stars", "35k"]]); +}); + +Deno.test("Maven Central", () => { + assertEquals(mavenQuery("com.google.guava:guava"), 'g:"com.google.guava" AND a:"guava"'); + assertEquals(mavenQuery(" guava "), "guava"); + const [guava] = mapMaven(MAVEN); + assertEquals([guava.name, guava.version, guava.page], [ + "com.google.guava:guava", + "33.5.0-jre", + "https://central.sonatype.com/artifact/com.google.guava/guava", + ]); + assertEquals(guava.install[0], ["Gradle (Kotlin)", 'implementation("com.google.guava:guava:33.5.0-jre")']); + assertEquals( + guava.install[2][1], + "<dependency>\n <groupId>com.google.guava</groupId>\n <artifactId>guava</artifactId>\n <version>33.5.0-jre</version>\n</dependency>", + ); + assertEquals(guava.updated, "2025-09-17T13:10:32.000Z"); +}); + +Deno.test("Hex", () => { + const [phoenix] = mapHex(HEX); + assertEquals([phoenix.version, phoenix.license, phoenix.downloads, phoenix.repository], [ + "1.8.16", + "MIT", + 158653497, + "https://github.com/phoenixframework/phoenix", + ]); + assertEquals(phoenix.install[0], ["mix.exs", '{:phoenix, "~> 1.8"}']); +}); + +Deno.test("pub.dev", () => { + assertEquals(pubNames({ packages: [{ package: "http" }, { package: "dio" }] }), ["http", "dio"]); + const http = mapPub(PUB, PUB_SCORE); + assertEquals([http.version, http.license, http.downloadsPeriod], ["1.6.0", "BSD-3-CLAUSE", "last 30 days"]); + assertEquals(http.install[0], ["dart pub add", "dart pub add http"]); + assertEquals(http.install[2], ["pubspec.yaml", "http: ^1.6.0"]); + assertEquals(http.facts?.map((f) => f[0]), ["Likes", "Pub points", "Publisher"]); + assertEquals(mapPub(PUB).downloads, undefined); +}); + +Deno.test("NuGet", () => { + const [newtonsoft, stj] = mapNuget(NUGET); + assertEquals([newtonsoft.name, newtonsoft.description, newtonsoft.license], [ + "Newtonsoft.Json", + "Json.NET is a popular high-performance JSON framework for .NET Second line", + undefined, + ]); + assertEquals(newtonsoft.install[0], ["dotnet add package", "dotnet add package Newtonsoft.Json"]); + assertEquals(newtonsoft.facts, [["Owners", "dotnetfoundation, jamesnk, newtonsoft"], ["Prefix", "Verified owner"]]); + assertEquals([stj.license, stj.facts?.[0]], ["MIT", ["Owners", "Microsoft"]]); +}); + +Deno.test("formatting and ranking", () => { + assertEquals([compact(950), compact(1234), compact(45_600), compact(2_300_000), compact(1_602_431_895)], [ + "950", + "1.2k", + "46k", + "2.3M", + "1.6B", + ]); + const now = new Date("2026-10-28T12:00:00Z"); + assertEquals( + [ + relativeDate("2026-10-28T08:00:00Z", now), + relativeDate("2026-10-20T08:00:00Z", now), + relativeDate("2026-05-01T00:00:00Z", now), + relativeDate("2023-01-01", now), + relativeDate(undefined, now), + ], + ["today", "8 days ago", "6 months ago", "4 years ago", undefined], + ); + const pkgs = mapGems([{ name: "rails-i18n" }, { name: "Rails" }]); + assertEquals(rank(pkgs, "rails").map((p) => p.name), ["Rails", "rails-i18n"]); +}); diff --git a/extensions/printers/backslash.toml b/extensions/printers/backslash.toml new file mode 100644 index 0000000..3413e28 --- /dev/null +++ b/extensions/printers/backslash.toml @@ -0,0 +1,48 @@ +[extension] +id = "printers" +title = "Printers" +description = "See your printers and their queues, cancel jobs, pause printers and print files" +version = "1.0.0" +author = "Backslash" +icon = "bs-printer" +license = "MIT" + +# CUPS client tools: lpstat lists printers and jobs, lpq names the queued +# files, lp prints, cancel removes jobs, lpoptions sets your default printer, +# cupsenable / cupsdisable / cupsaccept pause and resume (printer admin rights +# needed). The settings apps are only started to open the printer settings. +[permissions] +run = [ + "lpstat", + "lpq", + "lp", + "cancel", + "lpoptions", + "cupsenable", + "cupsdisable", + "cupsaccept", + "gnome-control-center", + "systemsettings", + "system-config-printer", +] +read = ["/etc/os-release"] +env = ["HOME", "XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "printers" +title = "Printers" +description = "Printers and print queues; type a file path to print it" +icon = "bs-printer" +keywords = [ + "printer", + "printers", + "print", + "imprimante", + "imprimantes", + "imprimer", + "impression", + "queue", + "file d'attente", + "cups", + "lp", +] diff --git a/extensions/printers/commands/printers.ts b/extensions/printers/commands/printers.ts new file mode 100644 index 0000000..5b173d8 --- /dev/null +++ b/extensions/printers/commands/printers.ts @@ -0,0 +1,426 @@ +import { + type Action, + action, + close, + type Context, + copy, + field, + form, + Icons, + type Item, + list, + meta, + open, + openExternal, + refresh, + submenu, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + connection, + expandPath, + explainCupsError, + installCups, + type Job, + looksLikePath, + lpstatProblem, + osFamily, + parseJobs, + parseLpq, + parseLpstat, + prettyName, + type Printer, + size, + type Status, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const DESKTOP = (Deno.env.get("XDG_CURRENT_DESKTOP") ?? "").toLowerCase(); +const CUPS = "http://localhost:631"; +const TEST_PAGE = "/usr/share/cups/data/testprint"; + +class NotInstalled extends Error {} + +/** Runs a CUPS tool in the C locale (its messages are translated otherwise). */ +async function cups(program: string, args: string[], signal?: AbortSignal): Promise<string> { + let res: Deno.CommandOutput; + try { + res = await new Deno.Command(program, { + args, + env: { LC_ALL: "C" }, + stdin: "null", + stdout: "piped", + stderr: "piped", + signal, + }).output(); + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NotInstalled(program); + throw err; + } + const dec = new TextDecoder(); + const out = dec.decode(res.stdout); + if (res.code !== 0) { + throw new Error(`${program} exited with ${res.code}: ${dec.decode(res.stderr).trim() || out.trim()}`); + } + return out; +} + +/** Like {@link cups}, with errors explained. */ +async function admin(program: string, args: string[]): Promise<string> { + try { + return await cups(program, args); + } catch (err) { + if (err instanceof NotInstalled) throw new Error(`${program} was not found: install the CUPS client tools`); + throw new Error(explainCupsError(String((err as Error).message))); + } +} + +async function status(signal: AbortSignal): Promise<Status & { jobs: Job[] }> { + const p = await cups("lpstat", ["-l", "-p", "-d"], signal); + const [a, v, o, q] = await Promise.all([ + cups("lpstat", ["-a"], signal).catch(() => ""), + cups("lpstat", ["-v"], signal).catch(() => ""), + cups("lpstat", ["-o"], signal).catch(() => ""), + // lpq (cups-bsd) knows the file names; optional. + cups("lpq", ["-a"], signal).catch(() => ""), + ]); + const st = parseLpstat(p, a, v); + const titles = parseLpq(q); + const jobs = parseJobs(o).map((j) => ({ ...j, ...titles.get(j.number) })); + return { ...st, jobs }; +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable) return false; + throw err; + } +} + +const settingsAction = action("Open Printer Settings", async () => { + const candidates: [string, string[]][] = DESKTOP.includes("kde") + ? [["systemsettings", ["kcm_printer_manager"]], ["system-config-printer", []]] + : [["gnome-control-center", ["printers"]], ["system-config-printer", []], ["systemsettings", [ + "kcm_printer_manager", + ]]]; + for (const [program, args] of candidates) if (launch(program, args)) return close(); + // No settings app: the CUPS web page does everything. + await openExternal(`${CUPS}/printers/`); + return close(); +}, { icon: Icons.settings, shortcut: "<Ctrl>," }); + +const webAction = open(`${CUPS}/printers/`, "Open CUPS Web Page", { icon: Icons.globe, shortcut: "<Ctrl>w" }); + +// --- Printing a file ------------------------------------------------------------- + +async function printFile(path: string, printer: string | undefined, options: string[] = []): Promise<string> { + if (!path.startsWith("/")) throw new Error(`Use an absolute path, e.g. ~/Documents/${path.split("/").pop()}`); + const out = await admin("lp", [...(printer ? ["-d", printer] : []), ...options, path]); + return out.match(/request id is (\S+)/)?.[1] ?? ""; +} + +function printForm(path: string, printers: Printer[], defaultName?: string) { + return form({ + title: "Print File", + submitTitle: "Print", + fields: [ + field.text("path", "File", { required: true, value: path, placeholder: "~/Documents/report.pdf" }), + field.dropdown( + "printer", + "Printer", + printers.map((p) => ({ + value: p.name, + title: `${p.description ?? prettyName(p.name)}${p.isDefault ? " (default)" : ""}`, + })), + { value: defaultName ?? printers[0]?.name }, + ), + field.text("copies", "Copies", { value: "1" }), + field.checkbox("duplex", "Two-sided"), + field.checkbox("gray", "Black and white"), + ], + async submit(values) { + const file = expandPath(String(values.path), HOME); + const copies = Math.max(1, Math.min(99, Number(values.copies) || 1)); + const id = await printFile(file, String(values.printer) || undefined, [ + "-n", + String(copies), + ...(values.duplex ? ["-o", "sides=two-sided-long-edge"] : []), + ...(values.gray ? ["-o", "print-color-mode=monochrome"] : []), + ]); + return close(`Sent to ${prettyName(String(values.printer))}${id ? ` (${id})` : ""}`); + }, + }); +} + +function printFileItem(query: string, st: Status): Item { + const file = expandPath(query, HOME); + const name = file.split("/").pop() ?? file; + const target = st.printers.find((p) => p.isDefault) ?? st.printers[0]; + const on = (p: Printer, options = {}) => + action(p.description ?? prettyName(p.name), async () => { + const id = await printFile(file, p.name); + return close(`Printing ${name} on ${prettyName(p.name)}${id ? ` (${id})` : ""}`); + }, { icon: Icons.printer, ...options }); + return { + id: "print-file", + title: `Print ${name}`, + subtitle: target ? `on ${target.description ?? prettyName(target.name)}` : undefined, + icon: { source: Icons.printer, tint: "blue" }, + section: "Print", + keywords: [query, file], + detail: { + metadata: [ + meta.text("File", file, { icon: Icons.file }), + ...(target + ? [meta.text("Printer", target.description ?? prettyName(target.name), { icon: Icons.printer })] + : []), + ], + }, + actions: [ + ...(target ? [on(target)] : []), + submenu("Print On", st.printers.map((p) => on(p)), { icon: Icons.printer }), + action("Print With Options…", () => printForm(query, st.printers, st.defaultName), { + icon: Icons.settings, + shortcut: "<Ctrl>p", + }), + ], + }; +} + +// --- Items ----------------------------------------------------------------------- + +function printerItem(p: Printer, jobs: Job[], all: Printer[]): Item { + const mine = jobs.filter((j) => j.printer === p.name); + const paused = p.state === "disabled"; + const label = p.description ?? prettyName(p.name); + const stateText = paused ? "Paused" : p.state === "printing" ? "Printing" : p.state === "idle" ? "Ready" : "Unknown"; + const color = paused ? "orange" : p.state === "printing" ? "blue" : "green"; + const actions: Action[] = [ + open(`${CUPS}/printers/${encodeURIComponent(p.name)}`, "Open in CUPS", { icon: Icons.globe }), + action("Print File…", () => printForm("", [p, ...all.filter((x) => x !== p)], p.name), { + icon: Icons.file, + shortcut: "<Ctrl>p", + }), + paused + ? action("Resume Printer", async () => { + await admin("cupsenable", [p.name]); + await admin("cupsaccept", [p.name]).catch(() => {}); + await toast(`${prettyName(p.name)} resumed`, { style: "success" }); + return refresh(); + }, { icon: Icons.play, shortcut: "<Ctrl>r", section: "Printer" }) + : action("Pause Printer", async () => { + await admin("cupsdisable", [p.name]); + await toast(`${prettyName(p.name)} paused`, { style: "success" }); + return refresh(); + }, { icon: Icons.pause, shortcut: "<Ctrl>r", section: "Printer" }), + ...(!p.isDefault + ? [action("Set as Default", async () => { + await admin("lpoptions", ["-d", p.name]); + await toast(`${prettyName(p.name)} is your default printer`, { style: "success" }); + return refresh(); + }, { icon: Icons.star, shortcut: "<Ctrl>d", section: "Printer" })] + : []), + action("Print Test Page", async () => { + const id = await printFile(TEST_PAGE, p.name); + await toast(`Test page sent${id ? ` (${id})` : ""}`, { style: "success" }); + return refresh(); + }, { icon: Icons.check, section: "Printer" }), + ...(mine.length + ? [action(`Cancel All Jobs (${mine.length})`, async () => { + await admin("cancel", ["-a", p.name]); + await toast(`Queue of ${prettyName(p.name)} cleared`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Cancel all ${mine.length} job${mine.length === 1 ? "" : "s"} on ${prettyName(p.name)}?`, + section: "Danger", + })] + : []), + copy(p.name, "Copy Queue Name", { section: "Copy" }), + ...(p.device ? [copy(p.device, "Copy Device URI", { section: "Copy" })] : []), + settingsAction, + ]; + return { + id: `printer:${p.name}`, + title: label, + subtitle: p.location ?? (label !== prettyName(p.name) ? p.name : undefined), + icon: { source: Icons.printer, tint: color }, + section: "Printers", + keywords: [p.name, p.location ?? "", p.device ?? "", stateText], + accessories: [ + ...(p.isDefault ? [{ icon: Icons.starFilled, tooltip: "Default printer", color: "yellow" as const }] : []), + ...(mine.length ? [{ text: `${mine.length} job${mine.length === 1 ? "" : "s"}`, icon: Icons.inbox }] : []), + ...(!p.accepting ? [{ tag: "Rejecting jobs", color: "red" as const }] : []), + { tag: stateText, color }, + ], + detail: { + stats: [ + { label: "State", value: stateText, color }, + { label: "Queued", value: mine.length }, + ], + markdown: [ + p.message ? `> ${p.message}` : "", + paused + ? "Paused printers keep their queue. Resuming needs printer admin rights: if it's refused, " + + "add yourself to the `lpadmin` group or use the CUPS web page." + : "", + ].filter(Boolean).join("\n\n") || undefined, + metadata: [ + meta.text("Queue", p.name, { icon: Icons.printer }), + ...(p.isDefault ? [meta.text("Default", "Yes", { icon: Icons.starFilled, color: "yellow" })] : []), + meta.text("Accepting jobs", p.accepting ? "Yes" : "No", { color: p.accepting ? undefined : "red" }), + ...(p.job ? [meta.text("Printing", p.job)] : []), + ...(p.alerts ? [meta.text("Alerts", p.alerts, { icon: Icons.alert, color: "orange" })] : []), + meta.separator(), + ...(p.location ? [meta.text("Location", p.location, { icon: Icons.pin })] : []), + ...(connection(p.device) ? [meta.text("Connection", connection(p.device)!, { icon: Icons.plug })] : []), + ...(p.since ? [meta.text(paused ? "Paused since" : "Since", p.since, { icon: Icons.clock })] : []), + meta.link("CUPS", "localhost:631", `${CUPS}/printers/${encodeURIComponent(p.name)}`), + ], + }, + actions, + }; +} + +function jobItem(j: Job & { rank?: string; title?: string }, printers: Printer[]): Item { + const printer = printers.find((p) => p.name === j.printer); + const active = j.rank === "active" || printer?.job === j.id; + return { + id: `job:${j.id}`, + title: j.title ?? `Job ${j.number}`, + subtitle: printer?.description ?? prettyName(j.printer), + icon: { source: active ? Icons.inProgress : Icons.hourglass, tint: active ? "blue" : "gray" }, + section: "Queue", + keywords: [j.id, j.user, j.printer], + accessories: [ + { text: size(j.size), tooltip: "Size" }, + { avatar: j.user, tooltip: j.user }, + active ? { tag: "Printing", color: "blue" } : { tag: j.rank ?? "Waiting", color: "gray" }, + ], + detail: { + metadata: [ + meta.text("Job", j.id, { icon: Icons.hash }), + ...(j.title ? [meta.text("Document", j.title, { icon: Icons.file })] : []), + meta.text("Printer", printer?.description ?? prettyName(j.printer), { icon: Icons.printer }), + meta.person("Owner", j.user), + meta.text("Size", size(j.size)), + meta.text("Submitted", j.date, { icon: Icons.clock }), + meta.text("Status", active ? "Printing" : j.rank ? `In queue (${j.rank})` : "In queue"), + ], + }, + actions: [ + action("Cancel Job", async () => { + await admin("cancel", [j.id]); + await toast(`Job ${j.id} cancelled`, { style: "success" }); + return refresh(); + }, { icon: Icons.cross, destructive: true, confirm: `Cancel ${j.title ?? `job ${j.id}`}?` }), + open(`${CUPS}/jobs/`, "Open Jobs in CUPS", { icon: Icons.globe }), + copy(j.id, "Copy Job ID", { section: "Copy" }), + ], + }; +} + +async function setupItems(kind: "missing" | "no-scheduler"): Promise<Item[]> { + const os = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const family = osFamily(os); + if (kind === "no-scheduler") { + const cmd = "sudo systemctl enable --now cups"; + return [{ + id: "no-scheduler", + title: "The printing service isn't running", + subtitle: "Start CUPS to see your printers", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: + `CUPS is installed but its service (cupsd) is stopped. Start it, and at every boot:\n\n\`\`\`\n${cmd}\n\`\`\``, + }, + actions: [copy(cmd, "Copy Command")], + }]; + } + const commands = installCups(family); + return [{ + id: "no-cups", + title: "CUPS isn't installed", + subtitle: "Linux prints through CUPS: install it to add printers", + icon: { source: Icons.printer, tint: "gray" }, + detail: { + markdown: [ + "Printers on Linux are managed by **CUPS**. Install it, start its service, then add a printer from the settings or the CUPS web page (http://localhost:631).", + "", + ...commands.flatMap((c) => [family ? "" : `**${c.family}**`, "```", c.command, "```"]), + ].filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n"), + }, + actions: [ + copy(commands[0].command, "Copy Install Command"), + open("https://www.cups.org", "Open cups.org", { icon: Icons.globe }), + ], + }]; +} + +const noPrinters: Item = { + id: "no-printers", + title: "No printers yet", + subtitle: "Add one in the settings or on the CUPS web page", + icon: { source: Icons.printer, tint: "gray" }, + detail: { + markdown: + "Network printers are usually found by themselves (IPP Everywhere). If yours isn't, add it from the printer settings or at http://localhost:631/admin.", + }, + actions: [settingsAction, open(`${CUPS}/admin`, "Add Printer in CUPS", { icon: Icons.plus })], +}; + +let summary = ""; + +export default (ctx: Context) => + list({ + placeholder: "Filter printers and jobs, or type a file path to print…", + actions: [webAction, settingsAction], + status: () => summary, + // Refreshed every 4 s while open: the queue moves as pages come out. + async *items({ signal }) { + let last = ""; + while (!signal.aborted) { + let items: Item[]; + try { + const st = await status(signal); + const queued = st.jobs.length; + summary = `${st.printers.length} printer${st.printers.length === 1 ? "" : "s"}${ + queued ? ` · ${queued} job${queued === 1 ? "" : "s"} queued` : "" + }${st.defaultName ? ` · default ${prettyName(st.defaultName)}` : ""}`; + updateCommandMetadata({ subtitle: queued ? `${queued} job${queued === 1 ? "" : "s"} queued` : null }) + .catch(() => {}); + items = st.printers.length === 0 ? [noPrinters] : [ + ...(looksLikePath(ctx.query) ? [printFileItem(ctx.query, st)] : []), + ...st.printers.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name)) + .map((p) => printerItem(p, st.jobs, st.printers)), + ...st.jobs.map((j) => jobItem(j, st.printers)), + ]; + } catch (err) { + if (signal.aborted) return; + if (err instanceof NotInstalled) items = await setupItems("missing"); + else { + const problem = lpstatProblem(String((err as Error).message)); + if (problem === "no-scheduler") items = await setupItems("no-scheduler"); + else if (problem === "no-printers") items = [noPrinters]; + else throw err; + } + summary = ""; + const key = JSON.stringify(items.map((i) => i.id)); + if (key !== last) yield items; + return; + } + const key = JSON.stringify(items.map((i) => [i.id, i.title, i.accessories, i.detail?.stats])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 4000)); + } + }, + }); diff --git a/extensions/printers/lib.ts b/extensions/printers/lib.ts new file mode 100644 index 0000000..e316770 --- /dev/null +++ b/extensions/printers/lib.ts @@ -0,0 +1,236 @@ +// Pure helpers for Printers: lpstat / lpq output (C locale) and what to install. + +export type PrinterState = "idle" | "printing" | "disabled" | "unknown"; + +export interface Printer { + name: string; + state: PrinterState; + /** The job being printed, e.g. `Office-57`. */ + job?: string; + /** "Paused", "Printing page 2, 45% complete.", "Waiting for printer to become available." */ + message?: string; + since?: string; + description?: string; + location?: string; + device?: string; + accepting: boolean; + isDefault: boolean; + alerts?: string; +} + +export interface Status { + printers: Printer[]; + defaultName?: string; +} + +/** + * `lpstat -l -p -d`, plus optionally `lpstat -a` (accepting) and `lpstat -v` (devices). + * Run with LC_ALL=C: lpstat translates its messages. + */ +export function parseLpstat(p: string, a = "", v = ""): Status { + const printers: Printer[] = []; + let defaultName: string | undefined; + let current: Printer | undefined; + for (const raw of p.split("\n")) { + const line = raw.replace(/\s+$/, ""); + const def = line.match(/^system default destination: (.+)$/); + if (def) { + defaultName = def[1].trim(); + continue; + } + const m = line.match(/^printer (\S+) (.*)$/); + if (m) { + const rest = m[2]; + let state: PrinterState = "unknown"; + let job: string | undefined; + if (/^is idle\b/.test(rest)) state = "idle"; + else if (/^now printing\b/.test(rest)) { + state = "printing"; + job = rest.match(/^now printing (\S+?)\.?\s/)?.[1]; + } else if (/^disabled\b/.test(rest)) state = "disabled"; + current = { + name: m[1], + state, + job, + since: rest.match(/since (.+?)(?:\s+-)?$/)?.[1], + accepting: true, + isDefault: false, + }; + printers.push(current); + continue; + } + if (!current || !/^\s/.test(line)) continue; + const t = line.trim(); + const kv = t.match( + /^(Description|Location|Alerts|Connection|Interface|On fault|After fault|Users allowed|Form mounted|Content types|Printer types):\s*(.*)$/, + ); + if (kv) { + if (kv[1] === "Description" && kv[2]) current.description = kv[2]; + if (kv[1] === "Location" && kv[2]) current.location = kv[2]; + if (kv[1] === "Alerts" && kv[2] && kv[2] !== "none") current.alerts = kv[2]; + continue; + } + if (/^(Banner required|Charset sets|Default pitch|Default page size|Default port settings|none|\(.*\))/i.test(t)) { + continue; + } + if (!current.message && t) current.message = t; + } + for (const line of a.split("\n")) { + const m = line.match(/^(\S+) (not )?accepting requests/); + const p = m && printers.find((x) => x.name === m[1]); + if (p) p.accepting = !m![2]; + } + for (const line of v.split("\n")) { + const m = line.match(/^device for (\S+): (.+)$/); + const p = m && printers.find((x) => x.name === m[1]); + if (p) p.device = m![2].trim(); + } + for (const p of printers) p.isDefault = p.name === defaultName; + return { printers, defaultName }; +} + +export interface Job { + /** `Office-57` */ + id: string; + printer: string; + number: number; + user: string; + size: number; + date: string; + title?: string; + rank?: string; +} + +/** `lpstat -o`: `Office-57 ada 1024000 Tue 28 Oct 2026 10:05:12 AM CET`. */ +export function parseJobs(out: string): Job[] { + const jobs: Job[] = []; + for (const line of out.split("\n")) { + const m = line.match(/^(\S+)-(\d+)\s+(\S+)\s+(\d+)\s+(.+)$/); + if (!m) continue; + // Printer names may contain dashes: the job number is after the last one. + jobs.push({ + id: `${m[1]}-${m[2]}`, + printer: m[1], + number: Number(m[2]), + user: m[3], + size: Number(m[4]), + date: m[5].trim(), + }); + } + return jobs; +} + +/** `lpq -a`: rank, owner, job number and file name of each job. */ +export function parseLpq(out: string): Map<number, { rank: string; title: string }> { + const map = new Map<number, { rank: string; title: string }>(); + for (const line of out.split("\n")) { + const m = line.match(/^(\S+)\s+(\S+)\s+(\d+)\s+(.+?)\s+(\d+) bytes\s*$/); + if (m && m[1] !== "Rank") map.set(Number(m[3]), { rank: m[1], title: m[4].trim() }); + } + return map; +} + +/** Why lpstat failed, in words a person can act on. */ +export function lpstatProblem(error: string): "no-scheduler" | "no-printers" | undefined { + if (/scheduler is not running|Unable to connect to server|connection refused/i.test(error)) return "no-scheduler"; + if (/No destinations added/i.test(error)) return "no-printers"; + return undefined; +} + +/** Turns cupsenable / cupsdisable / cancel errors into advice. */ +export function explainCupsError(error: string): string { + const msg = error.replace(/^\S+ exited with \d+: /, "").trim(); + if (/forbidden|not authorized|unauthorized|operation not permitted|permission denied|authentication/i.test(msg)) { + return "This needs printer admin rights: add yourself to the lpadmin group " + + "(sudo usermod -aG lpadmin $USER, then log in again) or use the CUPS web page"; + } + if (/scheduler is not running|Unable to connect/i.test(msg)) return "The printing service (cupsd) isn't running"; + return msg; +} + +export function size(bytes: number): string { + if (!bytes) return "–"; + const units = ["B", "KB", "MB", "GB"]; + let n = bytes; + let i = 0; + while (n >= 1000 && i < units.length - 1) { + n /= 1000; + i++; + } + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} + +/** "ipp://192.168.1.20/ipp/print" → "Network (IPP) · 192.168.1.20". */ +export function connection(device?: string): string | undefined { + if (!device) return undefined; + const scheme = device.split(":")[0].toLowerCase(); + const host = device.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^/:?]+)/i)?.[1]; + const kinds: Record<string, string> = { + ipp: "Network (IPP)", + ipps: "Network (IPP, secure)", + http: "Network (IPP)", + https: "Network (IPP, secure)", + socket: "Network (JetDirect)", + lpd: "Network (LPD)", + smb: "Windows share", + dnssd: "Network (Bonjour)", + implicitclass: "Network (shared)", + usb: "USB", + parallel: "Parallel port", + serial: "Serial port", + "cups-pdf": "PDF file", + hp: "HP (HPLIP)", + ippusb: "USB (IPP)", + }; + const kind = kinds[scheme] ?? scheme.toUpperCase(); + return host && !["usb", "hp", "ippusb"].includes(scheme) ? `${kind} · ${decodeURIComponent(host)}` : kind; +} + +/** A printer queue name made readable: `HP_LaserJet_M404` → `HP LaserJet M404`. */ +export function prettyName(name: string): string { + return name.replace(/_/g, " "); +} + +/** A path typed in the search bar, with `~` and `file://` understood. */ +export function expandPath(input: string, home: string): string { + let p = input.trim().replace(/^["']|["']$/g, ""); + if (p.startsWith("file://")) p = decodeURIComponent(p.slice(7)); + if (p === "~" || p.startsWith("~/")) p = home + p.slice(1); + return p; +} + +export function looksLikePath(input: string): boolean { + const t = input.trim(); + return /^(~\/|\/|file:\/\/)/.test(t) && /\.[a-z0-9]{1,5}$/i.test(t); +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +/** `/etc/os-release` → the distribution family. */ +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const INSTALL: Record<Family, string> = { + debian: "sudo apt install cups", + fedora: "sudo dnf install cups", + arch: "sudo pacman -S cups", + suse: "sudo zypper install cups", +}; + +/** The commands that install and start CUPS, for the family or every family when unknown. */ +export function installCups(family?: Family): { family: Family; command: string }[] { + return (family ? [family] : (Object.keys(INSTALL) as Family[])).map((f) => ({ + family: f, + command: `${INSTALL[f]} && sudo systemctl enable --now cups`, + })); +} diff --git a/extensions/printers/lib_test.ts b/extensions/printers/lib_test.ts new file mode 100644 index 0000000..6d2a795 --- /dev/null +++ b/extensions/printers/lib_test.ts @@ -0,0 +1,131 @@ +import { assert, assertEquals } from "@std/assert"; +import { + connection, + expandPath, + explainCupsError, + installCups, + looksLikePath, + lpstatProblem, + osFamily, + parseJobs, + parseLpq, + parseLpstat, + prettyName, + size, +} from "./lib.ts"; + +// CUPS 2.4, LC_ALL=C. +const LPSTAT_P = `printer Brother_HL-L2350DW disabled since Mon 27 Oct 2026 06:22:10 PM CET - + Paused + Form mounted: + Content types: any + Printer types: unknown + Description: Brother HL-L2350DW + Alerts: paused + Location: Study + Connection: direct + Interface: /etc/cups/ppd/Brother_HL-L2350DW.ppd +printer HP_LaserJet_M404 now printing HP_LaserJet_M404-58. enabled since Tue 28 Oct 2026 10:05:12 AM CET + Printing page 2, 45% complete. + Form mounted: + Description: HP LaserJet Pro M404dn + Alerts: none + Location: Office, 2nd floor + Users allowed: + (all) + Banner required +printer PDF is idle. enabled since Sun 26 Oct 2026 09:00:00 AM CET +system default destination: HP_LaserJet_M404 +`; +const LPSTAT_A = `Brother_HL-L2350DW not accepting requests since Mon 27 Oct 2026 06:22:10 PM CET - + Rejecting Jobs +HP_LaserJet_M404 accepting requests since Tue 28 Oct 2026 10:05:12 AM CET +PDF accepting requests since Sun 26 Oct 2026 09:00:00 AM CET +`; +const LPSTAT_V = `device for Brother_HL-L2350DW: usb://Brother/HL-L2350DW%20series?serial=U12345 +device for HP_LaserJet_M404: ipp://192.168.1.20/ipp/print +device for PDF: cups-pdf:/ +`; + +Deno.test("parseLpstat reads state, details, acceptance and devices", () => { + const { printers, defaultName } = parseLpstat(LPSTAT_P, LPSTAT_A, LPSTAT_V); + assertEquals(defaultName, "HP_LaserJet_M404"); + assertEquals(printers.map((p) => [p.name, p.state, p.accepting, p.isDefault]), [ + ["Brother_HL-L2350DW", "disabled", false, false], + ["HP_LaserJet_M404", "printing", true, true], + ["PDF", "idle", true, false], + ]); + const [brother, hp, pdf] = printers; + assertEquals([brother.message, brother.alerts, brother.location], ["Paused", "paused", "Study"]); + assertEquals(brother.since, "Mon 27 Oct 2026 06:22:10 PM CET"); + assertEquals([hp.job, hp.message, hp.description], [ + "HP_LaserJet_M404-58", + "Printing page 2, 45% complete.", + "HP LaserJet Pro M404dn", + ]); + assertEquals(hp.alerts, undefined); + assertEquals(hp.location, "Office, 2nd floor"); + assertEquals(pdf.device, "cups-pdf:/"); + assertEquals(parseLpstat("no system default destination\n").printers, []); +}); + +Deno.test("parseJobs and parseLpq", () => { + const jobs = parseJobs( + "HP_LaserJet_M404-58 ada 1024000 Tue 28 Oct 2026 10:05:12 AM CET\n" + + "Brother_HL-L2350DW-7 bob 2048 Tue 28 Oct 2026 10:06:40 AM CET\n", + ); + assertEquals(jobs.map((j) => [j.id, j.printer, j.number, j.user, j.size]), [ + ["HP_LaserJet_M404-58", "HP_LaserJet_M404", 58, "ada", 1024000], + ["Brother_HL-L2350DW-7", "Brother_HL-L2350DW", 7, "bob", 2048], + ]); + assertEquals(jobs[0].date, "Tue 28 Oct 2026 10:05:12 AM CET"); + const lpq = parseLpq( + "HP_LaserJet_M404 is ready and printing\nRank Owner Job File(s) Total Size\n" + + "active ada 58 Quarterly report.pdf 1024000 bytes\n1st ada 59 notes.txt 2048 bytes\n", + ); + assertEquals(lpq.get(58), { rank: "active", title: "Quarterly report.pdf" }); + assertEquals(lpq.get(59)?.rank, "1st"); +}); + +Deno.test("errors become advice", () => { + assertEquals(lpstatProblem("lpstat exited with 1: lpstat: Scheduler is not running."), "no-scheduler"); + assertEquals(lpstatProblem("lpstat: No destinations added."), "no-printers"); + assertEquals(lpstatProblem("something else"), undefined); + assert(explainCupsError("cupsdisable exited with 1: cupsdisable: Forbidden").includes("lpadmin")); + assertEquals( + explainCupsError("cancel exited with 1: cancel: Job #9 does not exist."), + "cancel: Job #9 does not exist.", + ); +}); + +Deno.test("connection describes device URIs", () => { + assertEquals(connection("ipp://192.168.1.20/ipp/print"), "Network (IPP) · 192.168.1.20"); + assertEquals(connection("usb://Brother/HL-L2350DW%20series?serial=U12345"), "USB"); + assertEquals(connection("socket://printer.lan:9100"), "Network (JetDirect) · printer.lan"); + assertEquals(connection("dnssd://HP%20M404._ipp._tcp.local/"), "Network (Bonjour) · HP M404._ipp._tcp.local"); + assertEquals(connection(undefined), undefined); +}); + +Deno.test("small helpers", () => { + assertEquals(size(1024000), "1.0 MB"); + assertEquals(size(2048), "2.0 KB"); + assertEquals(size(512), "512 B"); + assertEquals(prettyName("HP_LaserJet_M404"), "HP LaserJet M404"); + assertEquals(expandPath("~/Documents/a.pdf", "/home/ada"), "/home/ada/Documents/a.pdf"); + assertEquals(expandPath("file:///tmp/My%20File.pdf", "/home/ada"), "/tmp/My File.pdf"); + assert(looksLikePath("~/Documents/report.pdf")); + assert(looksLikePath("/tmp/photo.jpg")); + assert(!looksLikePath("HP")); + assert(!looksLikePath("~/Documents")); +}); + +Deno.test("install hints follow the distribution", () => { + assertEquals(osFamily('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n'), "debian"); + assertEquals(osFamily('ID="opensuse-tumbleweed"\nID_LIKE="opensuse suse"\n'), "suse"); + assertEquals(osFamily("ID=nixos\n"), undefined); + assertEquals(installCups("arch"), [{ + family: "arch", + command: "sudo pacman -S cups && sudo systemctl enable --now cups", + }]); + assertEquals(installCups().length, 4); +}); diff --git a/extensions/privacy/backslash.toml b/extensions/privacy/backslash.toml new file mode 100644 index 0000000..22ef7f8 --- /dev/null +++ b/extensions/privacy/backslash.toml @@ -0,0 +1,51 @@ +[extension] +id = "privacy" +title = "Privacy" +description = "See which apps use your camera, microphone, screen and location, and stop them" +version = "1.0.0" +author = "Backslash" +icon = "bs-shield" +license = "MIT" + +# pw-dump (PipeWire) shows camera, screen-sharing and microphone streams; +# pactl (pipewire-pulse or PulseAudio) lists recording streams and mutes them; +# find and fuser tell who holds /dev/video*; ps names the processes; kill quits +# them; busctl asks GeoClue which apps follow your location. The settings apps +# are only started to open the matching settings page. +[permissions] +run = [ + "pw-dump", + "pactl", + "find", + "fuser", + "ps", + "kill", + "busctl", + "gnome-control-center", + "systemsettings", + "pavucontrol", +] +env = ["XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "privacy" +title = "Privacy" +description = "Apps using the camera, microphone, screen sharing or location right now" +icon = "bs-shield" +keywords = [ + "privacy", + "confidentialité", + "vie privée", + "camera", + "caméra", + "webcam", + "microphone", + "micro", + "mic", + "screen sharing", + "partage d'écran", + "location", + "localisation", + "who is listening", + "spy", +] diff --git a/extensions/privacy/commands/privacy.ts b/extensions/privacy/commands/privacy.ts new file mode 100644 index 0000000..1c357fb --- /dev/null +++ b/extensions/privacy/commands/privacy.ts @@ -0,0 +1,352 @@ +import { + type Action, + action, + close, + type Color, + copy, + exec, + Icons, + type Item, + list, + meta, + type Metadata, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + busValue, + clock, + deviceUses, + type Kind, + KIND_TITLE, + mergeUses, + parseFuser, + parseGeoclueClients, + parsePs, + parsePwDump, + parseSourceOutputs, + prettyApp, + type Proc, + since, + subtitle, + type Use, +} from "../lib.ts"; + +const DESKTOP = (Deno.env.get("XDG_CURRENT_DESKTOP") ?? "").toLowerCase(); + +/** Runs a program; undefined when it is missing or fails. */ +async function tryRun(program: string, args: string[], signal?: AbortSignal): Promise<string | undefined> { + try { + const { code, stdout } = await new Deno.Command(program, { + args, + stdin: "null", + stdout: "piped", + stderr: "null", + signal, + }).output(); + const out = new TextDecoder().decode(stdout); + // fuser exits with 1 when nobody holds the file. + return code === 0 || program === "fuser" ? out : undefined; + } catch { + return undefined; + } +} + +interface Scan { + uses: Use[]; + procs: Map<number, Proc>; + /** What could be checked. */ + pipewire: boolean; + pulse: boolean; + cameras: string[]; +} + +async function cameraHolders(signal: AbortSignal): Promise<{ device: string; pids: number[] }[]> { + // find instead of reading /dev from Deno: only the names of the video devices are needed. + const list = await tryRun("find", ["/dev", "-maxdepth", "1", "-name", "video*"], signal); + const devices = (list ?? "").split("\n").map((l) => l.trim()).filter(Boolean).sort(); + return await Promise.all(devices.map(async (device) => ({ + device, + pids: parseFuser(await tryRun("fuser", [device], signal) ?? ""), + }))); +} + +async function geoclue(signal: AbortSignal): Promise<Use[]> { + const tree = await tryRun("busctl", ["--system", "--list", "tree", "org.freedesktop.GeoClue2"], signal); + if (!tree) return []; + const uses: Use[] = []; + for (const path of parseGeoclueClients(tree)) { + const get = (prop: string) => + tryRun("busctl", [ + "--system", + "--json=short", + "get-property", + "org.freedesktop.GeoClue2", + path, + "org.freedesktop.GeoClue2.Client", + prop, + ], signal).then((o) => (o ? busValue(o) : undefined)); + const [active, id] = await Promise.all([get("Active"), get("DesktopId")]); + if (active !== true) continue; + const appId = typeof id === "string" && id ? id : undefined; + uses.push({ + kind: "location", + key: `location:${appId ?? path}`, + app: appId ? prettyApp(appId.split(".").pop()!) : "An app", + appId, + device: "GeoClue", + via: "geoclue", + }); + } + return uses; +} + +async function scan(signal: AbortSignal): Promise<Scan> { + const [dump, outputs, sources, holders, location] = await Promise.all([ + tryRun("pw-dump", [], signal), + tryRun("pactl", ["-f", "json", "list", "source-outputs"], signal), + tryRun("pactl", ["-f", "json", "list", "sources"], signal), + cameraHolders(signal), + geoclue(signal), + ]); + const pids = [...new Set(holders.flatMap((h) => h.pids))]; + const pw = dump ? parsePwDump(dump) : []; + const pulse = outputs ? parseSourceOutputs(outputs, sources ?? "[]") : []; + // Ask ps about every pid we know of, for names and start times. + const allPids = [...new Set([...pids, ...pw.map((u) => u.pid), ...pulse.map((u) => u.pid)])].filter(( + p, + ): p is number => !!p); + const procs = parsePs( + allPids.length + ? await tryRun("ps", ["-o", "pid=,etimes=,user=,comm=,args=", "-p", allPids.join(",")], signal) ?? "" + : "", + ); + const direct = deviceUses(holders, procs); + // PulseAudio first for the microphone: it knows the stream and source to mute. + const uses = mergeUses(pulse, pw, direct, location); + // The camera held only by PipeWire with no app found: still in use. + const pwHeld = holders.filter((h) => h.pids.some((p) => procs.get(p)?.comm === "pipewire")); + if (!uses.some((u) => u.kind === "camera") && pwHeld.length && !dump) { + uses.push({ + kind: "camera", + key: "camera:pipewire", + app: "An app (through PipeWire)", + device: pwHeld[0].device, + via: "device", + }); + } + return { + uses, + procs, + pipewire: !!dump, + pulse: !!outputs, + cameras: holders.map((h) => h.device), + }; +} + +// --- "In use since" -------------------------------------------------------------- + +const SEEN_KEY = "first-seen"; +let seen: Record<string, number> | undefined; + +async function firstSeen(uses: Use[]): Promise<Record<string, number>> { + seen ??= (await storage.get<Record<string, number>>(SEEN_KEY).catch(() => undefined)) ?? {}; + const now = Date.now(); + const next: Record<string, number> = {}; + for (const u of uses) next[u.key] = seen[u.key] ?? now; + const changed = Object.keys(next).length !== Object.keys(seen).length || + Object.keys(next).some((k) => seen![k] !== next[k]); + seen = next; + if (changed) storage.set(SEEN_KEY, next).catch(() => {}); + return next; +} + +// --- Settings ---------------------------------------------------------------------- + +function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound || err instanceof Deno.errors.NotCapable) return false; + throw err; + } +} + +const GNOME_PANEL: Record<Kind, string> = { + camera: "camera", + microphone: "microphone", + screen: "sharing", + location: "location", +}; + +function settings(kind: Kind): Action { + return action(`Open ${kind === "screen" ? "Sharing" : KIND_TITLE[kind]} Settings`, () => { + const tries: [string, string[]][] = DESKTOP.includes("kde") + ? [["systemsettings", [kind === "microphone" ? "kcm_pulseaudio" : "kcm_app-permissions"]]] + : [["gnome-control-center", [GNOME_PANEL[kind]]], ["gnome-control-center", ["privacy"]]]; + if (kind === "microphone") tries.push(["pavucontrol", ["--tab=2"]]); + for (const [program, args] of tries) if (launch(program, args)) return close(); + throw new Error("No settings app found (GNOME Settings, KDE System Settings or pavucontrol)"); + }, { icon: Icons.settings, shortcut: "<Ctrl>," }); +} + +// --- Items ------------------------------------------------------------------------- + +const KIND_ICON: Record<Kind, string> = { + camera: Icons.camera, + microphone: Icons.mic, + screen: Icons.monitor, + location: Icons.pin, +}; +const KIND_COLOR: Record<Kind, Color> = { camera: "green", microphone: "orange", screen: "blue", location: "purple" }; + +function useItem(u: Use, procs: Map<number, Proc>, firstAt: number): Item { + const p = u.pid ? procs.get(u.pid) : undefined; + const forMs = Date.now() - firstAt; + const color: Color = u.paused ? "gray" : KIND_COLOR[u.kind]; + const kill = (signal: "TERM" | "KILL", title: string, options: Record<string, unknown>) => + action(title, async () => { + await exec("kill", [`-${signal}`, String(u.pid)]); + await toast(`${signal === "TERM" ? "Asked" : "Forced"} ${u.app} to quit`, { style: "success" }); + }, { destructive: true, section: "App", ...options }); + const actions: Action[] = []; + if (u.kind === "microphone" && u.sourceName) { + actions.push(action(u.sourceMuted ? "Unmute Microphone" : "Mute Microphone", async () => { + await exec("pactl", ["set-source-mute", u.sourceName!, u.sourceMuted ? "0" : "1"]); + await toast(u.sourceMuted ? "Microphone unmuted" : "Microphone muted for every app", { style: "success" }); + }, { icon: u.sourceMuted ? Icons.mic : Icons.speakerOff, shortcut: "<Ctrl>m" })); + } + if (u.kind === "microphone" && u.streamIndex !== undefined) { + actions.push(action(u.streamMuted ? `Unmute for ${u.app}` : `Mute for ${u.app} Only`, async () => { + await exec("pactl", ["set-source-output-mute", String(u.streamIndex), u.streamMuted ? "0" : "1"]); + await toast(`${u.app} ${u.streamMuted ? "hears you again" : "no longer hears you"}`, { style: "success" }); + }, { icon: Icons.speakerOff })); + } + if (u.pid) { + actions.push( + kill("TERM", `Quit ${u.app}`, { + icon: Icons.stop, + confirm: `Quit ${u.app} (pid ${u.pid})? Unsaved work may be lost.`, + shortcut: "<Ctrl>k", + }), + kill("KILL", "Force Quit", { icon: Icons.cross, confirm: `Kill ${u.app} right away?` }), + ); + } + actions.push(settings(u.kind)); + if (u.pid) actions.push(copy(String(u.pid), "Copy PID", { section: "Copy" })); + if (p?.args) actions.push(copy(p.args, "Copy Command Line", { section: "Copy" })); + + const metadata: Metadata[] = [ + meta.text("App", u.app, { icon: KIND_ICON[u.kind] }), + ...(u.appId ? [meta.text("App ID", u.appId)] : []), + ...(u.device ? [meta.text(u.kind === "screen" ? "Stream" : "Device", u.device, { icon: Icons.plug })] : []), + meta.text("Status", u.paused ? "Paused" : "In use", { color }), + meta.text("In use for", `${forMs < 60_000 ? "Just now" : since(forMs / 1000)} (since ${clock(firstAt)})`, { + icon: Icons.clock, + }), + meta.separator(), + ...(u.pid ? [meta.text("PID", u.pid)] : []), + ...(p + ? [meta.text("Program", p.comm, { icon: Icons.terminal })] + : u.binary + ? [meta.text("Program", u.binary)] + : []), + ...(p ? [meta.text("Running for", since(p.elapsed))] : []), + ...(p ? [meta.person("User", p.user)] : []), + ...(u.kind === "microphone" && u.sourceMuted !== undefined + ? [meta.text("Microphone", u.sourceMuted ? "Muted" : "Live", { color: u.sourceMuted ? "gray" : "orange" })] + : []), + meta.text( + "Found with", + { pipewire: "PipeWire", pulse: "PulseAudio", device: "Device files", geoclue: "GeoClue" }[u.via], + ), + ]; + return { + id: u.key, + title: u.app, + subtitle: u.device, + icon: { source: KIND_ICON[u.kind], tint: color }, + section: KIND_TITLE[u.kind], + keywords: [u.binary ?? "", u.appId ?? "", String(u.pid ?? ""), u.kind], + accessories: [ + ...(u.kind === "microphone" && u.sourceMuted ? [{ icon: Icons.speakerOff, tooltip: "Microphone muted" }] : []), + { + text: forMs < 60_000 ? "just now" : since(forMs / 1000), + icon: Icons.clock, + tooltip: `Since ${clock(firstAt)}`, + }, + u.paused ? { tag: "Paused", color: "gray" } : { tag: u.kind === "screen" ? "Sharing" : "In use", color }, + ], + detail: { + markdown: p?.args ? "```\n" + (p.args.length > 400 ? p.args.slice(0, 400) + "…" : p.args) + "\n```" : undefined, + metadata, + }, + actions, + }; +} + +function idleItem(kind: Kind, note?: { title: string; text: string }): Item { + return { + id: `idle:${kind}`, + title: note?.title ?? "Not in use", + subtitle: note?.text, + icon: { source: note ? Icons.question : KIND_ICON[kind], tint: "gray" }, + section: KIND_TITLE[kind], + keywords: [kind], + accessories: note ? [] : [{ icon: Icons.check, tooltip: "Nothing is using it" }], + detail: { + markdown: note ? note.text : `No app is using the ${KIND_TITLE[kind].toLowerCase()} right now.`, + }, + actions: [settings(kind)], + }; +} + +let summary = ""; + +export default list({ + placeholder: "Filter apps…", + status: () => summary, + // Checked every 3 s while open. + async *items({ signal }) { + let last = ""; + let lastSubtitle: string | null | undefined; + while (!signal.aborted) { + const s = await scan(signal); + if (signal.aborted) return; + const firsts = await firstSeen(s.uses); + const text = subtitle(s.uses); + if (text !== lastSubtitle) { + lastSubtitle = text; + updateCommandMetadata({ subtitle: text }).catch(() => {}); + } + summary = text ?? "Nothing is watching or listening"; + const items: Item[] = []; + const notes: Record<Kind, { title: string; text: string } | undefined> = { + camera: s.cameras.length === 0 ? { title: "No camera", text: "No video device is connected" } : undefined, + microphone: !s.pulse && !s.pipewire + ? { + title: "Can't check", + text: "Install pipewire-pulse or pulseaudio-utils (pactl), or PipeWire's pw-dump, to see who records", + } + : undefined, + screen: !s.pipewire + ? { title: "Can't check", text: "Screen sharing is seen through PipeWire: pw-dump isn't available" } + : undefined, + location: undefined, + }; + for (const kind of ["camera", "microphone", "screen", "location"] as Kind[]) { + const mine = s.uses.filter((u) => u.kind === kind).sort((a, b) => Number(a.paused) - Number(b.paused)); + if (mine.length) items.push(...mine.map((u) => useItem(u, s.procs, firsts[u.key] ?? Date.now()))); + else items.push(idleItem(kind, notes[kind])); + } + const key = JSON.stringify(items.map((i) => [i.id, i.subtitle, i.accessories, i.actions?.map((a) => a.title)])); + if (key !== last) { + last = key; + yield items; + } + await new Promise((r) => setTimeout(r, 3000)); + } + }, +}); diff --git a/extensions/privacy/lib.ts b/extensions/privacy/lib.ts new file mode 100644 index 0000000..8f777c5 --- /dev/null +++ b/extensions/privacy/lib.ts @@ -0,0 +1,357 @@ +// Pure helpers for Privacy: who uses the camera, the microphone, the screen and the location. + +export type Kind = "camera" | "microphone" | "screen" | "location"; + +/** One app using a sensitive device. */ +export interface Use { + kind: Kind; + /** A stable key, for "in use since". */ + key: string; + app: string; + pid?: number; + binary?: string; + /** Flatpak / portal app id. */ + appId?: string; + /** The device or source used: "Integrated Camera", "Built-in Audio Analog Stereo"… */ + device?: string; + /** How it was found. */ + via: "pipewire" | "pulse" | "device" | "geoclue"; + /** PulseAudio source-output index, to mute the stream. */ + streamIndex?: number; + /** Name of the PulseAudio source (microphone), to mute it. */ + sourceName?: string; + sourceMuted?: boolean; + streamMuted?: boolean; + /** Corked: the stream exists but is paused. */ + paused?: boolean; +} + +// --------------------------------------------------------------------------- +// PipeWire (pw-dump) + +interface PwObject { + id: number; + type: string; + info?: { + props?: Record<string, unknown>; + state?: string; + "output-node-id"?: number; + "input-node-id"?: number; + }; +} + +const str = (v: unknown) => (v === undefined || v === null || v === "" ? undefined : String(v)); +const int = (v: unknown) => { + const n = Number(v); + return v !== undefined && v !== null && v !== "" && Number.isFinite(n) ? n : undefined; +}; + +/** Daemons that hold devices on behalf of apps: they aren't "the app using the camera". */ +export const PLUMBING = new Set([ + "pipewire", + "wireplumber", + "pipewire-media-session", + "pipewire-pulse", + "pulseaudio", + "xdg-desktop-portal", +]); + +const SCREEN_HINT = + /screen|screencast|xdpw|xdg-desktop-portal|gnome-shell|mutter|kwin|hyprland|wlroots|monitor|desktop/i; + +/** Camera, microphone and screen-sharing users seen in `pw-dump`. */ +export function parsePwDump(json: string): Use[] { + let objects: PwObject[]; + try { + objects = JSON.parse(json); + } catch { + return []; + } + if (!Array.isArray(objects)) return []; + const nodes = new Map<number, PwObject>(); + const clients = new Map<number, Record<string, unknown>>(); + const links: { from: number; to: number; state?: string }[] = []; + for (const o of objects) { + if (o.type === "PipeWire:Interface:Node") nodes.set(o.id, o); + else if (o.type === "PipeWire:Interface:Client") clients.set(o.id, o.info?.props ?? {}); + else if (o.type === "PipeWire:Interface:Link" && o.info) { + const from = o.info["output-node-id"]; + const to = o.info["input-node-id"]; + if (from !== undefined && to !== undefined) links.push({ from, to, state: o.info.state }); + } + } + const props = (n: PwObject) => n.info?.props ?? {}; + const cls = (n?: PwObject) => str(n && props(n)["media.class"]) ?? ""; + /** The app behind a node: its own props, else its client's. */ + const owner = (n: PwObject) => { + const p = props(n); + const c = clients.get(int(p["client.id"]) ?? -1) ?? {}; + const pick = (k: string) => str(p[k]) ?? str(c[k]); + return { + app: pick("application.name") ?? pick("application.process.binary") ?? str(p["node.name"]) ?? "Unknown app", + pid: int(p["application.process.id"]) ?? int(c["application.process.id"]), + binary: pick("application.process.binary"), + appId: pick("pipewire.access.portal.app_id") ?? pick("flatpak.app_id"), + }; + }; + const label = (n: PwObject) => + str(props(n)["node.description"]) ?? str(props(n)["node.nick"]) ?? str(props(n)["node.name"]); + const isCamera = (n: PwObject) => { + const p = props(n); + return cls(n) === "Video/Source" && + (/^(v4l2|libcamera)$/.test(str(p["device.api"]) ?? "") || /^(v4l2|libcamera)/.test(str(p["node.name"]) ?? "") || + /^(v4l2|libcamera):/.test(str(p["object.path"]) ?? "")); + }; + const isScreen = (n: PwObject) => { + if (cls(n) !== "Video/Source" || isCamera(n)) return false; + const o = owner(n); + const text = [str(props(n)["node.name"]), label(n), o.app, o.binary].join(" "); + return SCREEN_HINT.test(text); + }; + const isMic = (n?: PwObject) => !!n && /^Audio\/Source(\/Virtual)?$/.test(cls(n)); + + const uses: Use[] = []; + const consumersOf = (src: PwObject) => + links.filter((l) => l.from === src.id && l.state !== "error").map((l) => nodes.get(l.to)).filter(( + n, + ): n is PwObject => !!n && cls(n).startsWith("Stream/Input")); + + for (const n of nodes.values()) { + if (isCamera(n) || isScreen(n)) { + const kind: Kind = isCamera(n) ? "camera" : "screen"; + const consumers = [...new Map(consumersOf(n).map((c) => [c.id, c])).values()]; + for (const c of consumers) { + const o = owner(c); + uses.push({ kind, key: `${kind}:pw:${o.pid ?? o.app}`, ...o, device: label(n), via: "pipewire" }); + } + // A running screencast whose viewer can't be told: the screen is shared all the same. + if (consumers.length === 0 && n.info?.state === "running" && kind === "screen") { + const o = owner(n); + uses.push({ kind, key: `screen:pw:${n.id}`, ...o, device: label(n), via: "pipewire" }); + } + } + } + for (const n of nodes.values()) { + if (cls(n) !== "Stream/Input/Audio") continue; + const sources = links.filter((l) => l.to === n.id).map((l) => nodes.get(l.from)).filter(isMic); + if (sources.length === 0) continue; + const o = owner(n); + if (PLUMBING.has(o.binary ?? "")) continue; + uses.push({ + kind: "microphone", + key: `microphone:pw:${o.pid ?? o.app}`, + ...o, + device: label(sources[0]!), + via: "pipewire", + paused: n.info?.state !== undefined && n.info.state !== "running", + }); + } + return dedupe(uses); +} + +// --------------------------------------------------------------------------- +// PulseAudio / pipewire-pulse (pactl -f json) + +interface PaSource { + index: number; + name: string; + description?: string; + mute?: boolean; + properties?: Record<string, string>; +} + +interface PaSourceOutput { + index: number; + source: number; + client?: string; + corked?: boolean; + mute?: boolean; + properties?: Record<string, string>; +} + +/** Recording streams from `pactl -f json list source-outputs`, monitors and level meters left out. */ +export function parseSourceOutputs(outputsJson: string, sourcesJson: string): Use[] { + let outputs: PaSourceOutput[]; + let sources: PaSource[]; + try { + outputs = JSON.parse(outputsJson); + sources = JSON.parse(sourcesJson || "[]"); + } catch { + return []; + } + if (!Array.isArray(outputs)) return []; + const byIndex = new Map((Array.isArray(sources) ? sources : []).map((s) => [s.index, s])); + const uses: Use[] = []; + for (const o of outputs) { + const p = o.properties ?? {}; + const src = byIndex.get(o.source); + const isMonitor = src ? src.name.endsWith(".monitor") || src.properties?.["device.class"] === "monitor" : false; + if (isMonitor) continue; + // pavucontrol's and desktop sound panels' level meters. + if (p["media.name"] === "Peak detect" || p["stream.monitor"] === "true" || p["media.role"] === "abstract") continue; + if (PLUMBING.has(p["application.process.binary"] ?? "")) continue; + const pid = int(p["application.process.id"]); + const app = p["application.name"] ?? p["application.process.binary"] ?? p["node.name"] ?? "Unknown app"; + uses.push({ + kind: "microphone", + key: `microphone:pw:${pid ?? app}`, + app, + pid, + binary: p["application.process.binary"], + appId: p["pipewire.access.portal.app_id"] ?? p["application.id"], + device: src?.description ?? src?.name, + via: "pulse", + streamIndex: o.index, + sourceName: src?.name, + sourceMuted: src?.mute, + streamMuted: o.mute, + paused: o.corked, + }); + } + return dedupe(uses); +} + +/** One row per app and kind (an app may open several streams). */ +function dedupe(uses: Use[]): Use[] { + const out = new Map<string, Use>(); + for (const u of uses) { + const prev = out.get(u.key); + if (!prev || (prev.paused && !u.paused)) out.set(u.key, u); + } + return [...out.values()]; +} + +// --------------------------------------------------------------------------- +// Devices and processes + +/** `fuser /dev/video0` stdout: ` 1234 5678m` → pids. */ +export function parseFuser(out: string): number[] { + return [...out.matchAll(/(\d+)[a-zA-Z]*/g)].map((m) => Number(m[1])); +} + +export interface Proc { + pid: number; + /** Seconds since it started. */ + elapsed: number; + user: string; + comm: string; + args: string; +} + +/** `ps -o pid=,etimes=,user=,comm=,args= -p …` */ +export function parsePs(out: string): Map<number, Proc> { + const map = new Map<number, Proc>(); + for (const line of out.split("\n")) { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/); + if (m) map.set(Number(m[1]), { pid: Number(m[1]), elapsed: Number(m[2]), user: m[3], comm: m[4], args: m[5] }); + } + return map; +} + +/** Camera users found by holding /dev/video*, PipeWire's own processes left out. */ +export function deviceUses(holders: { device: string; pids: number[] }[], procs: Map<number, Proc>): Use[] { + const uses: Use[] = []; + for (const { device, pids } of holders) { + for (const pid of pids) { + const p = procs.get(pid); + if (p && PLUMBING.has(p.comm)) continue; + const name = p?.comm ?? `PID ${pid}`; + uses.push({ + kind: "camera", + key: `camera:pw:${pid}`, + app: prettyApp(name), + pid, + binary: p?.comm, + device, + via: "device", + }); + } + } + return dedupe(uses); +} + +/** "firefox" → "Firefox", "google-chrome" → "Google Chrome". */ +export function prettyApp(name: string): string { + if (/[A-Z]/.test(name)) return name; + return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Merges the sources: the first one to report an app (by pid) wins, later ones fill the blanks. */ +export function mergeUses(...lists: Use[][]): Use[] { + const out = new Map<string, Use>(); + for (const list of lists) { + for (const u of list) { + const key = u.pid ? `${u.kind}:pid:${u.pid}` : u.key; + const prev = out.get(key); + if (!prev) out.set(key, { ...u, key: u.pid ? `${u.kind}:${u.pid}` : u.key }); + else { + for (const [k, v] of Object.entries(u)) { + if ((prev as unknown as Record<string, unknown>)[k] === undefined) { + (prev as unknown as Record<string, unknown>)[k] = v; + } + } + } + } + } + return [...out.values()]; +} + +// --------------------------------------------------------------------------- +// GeoClue (busctl) + +/** Client object paths from `busctl --list tree org.freedesktop.GeoClue2`. */ +export function parseGeoclueClients(out: string): string[] { + return out.split("\n").map((l) => l.trim().replace(/^[└├│─\s]+/, "")).filter((l) => + /^\/org\/freedesktop\/GeoClue2\/Client\/\d+$/.test(l) + ); +} + +/** `busctl --json=short get-property …` → the value. */ +export function busValue(json: string): unknown { + try { + return JSON.parse(json)?.data; + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Words + +export const KIND_TITLE: Record<Kind, string> = { + camera: "Camera", + microphone: "Microphone", + screen: "Screen Sharing", + location: "Location", +}; + +/** "Camera and microphone in use", "Screen shared", or null. */ +export function subtitle(uses: Use[]): string | null { + const active = uses.filter((u) => !u.paused); + const has = (k: Kind) => active.some((u) => u.kind === k); + const devices = [has("camera") && "camera", has("microphone") && "microphone", has("location") && "location"] + .filter((x): x is string => !!x); + const parts: string[] = []; + if (devices.length) { + const words = devices.length === 1 ? devices[0] : `${devices.slice(0, -1).join(", ")} and ${devices.at(-1)}`; + parts.push(`${words[0].toUpperCase()}${words.slice(1)} in use`); + } + if (has("screen")) parts.push(parts.length ? "screen shared" : "Screen shared"); + return parts.length ? parts.join(", ") : null; +} + +/** "3 min", "1 h 05 min", "12 s". */ +export function since(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} h ${String(m % 60).padStart(2, "0")} min`; + return `${Math.floor(h / 24)} d ${h % 24} h`; +} + +export function clock(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} diff --git a/extensions/privacy/lib_test.ts b/extensions/privacy/lib_test.ts new file mode 100644 index 0000000..77c1066 --- /dev/null +++ b/extensions/privacy/lib_test.ts @@ -0,0 +1,218 @@ +import { assert, assertEquals } from "@std/assert"; +import { + busValue, + deviceUses, + mergeUses, + parseFuser, + parseGeoclueClients, + parsePs, + parsePwDump, + parseSourceOutputs, + prettyApp, + since, + subtitle, + type Use, +} from "./lib.ts"; + +// pw-dump of PipeWire 1.0, trimmed: Firefox uses the camera and the microphone, cava reads the +// speakers' monitor (not the mic), OBS records a screencast from xdg-desktop-portal-wlr. +const PW = JSON.stringify([ + { id: 0, type: "PipeWire:Interface:Core", info: { name: "pipewire-0" } }, + { + id: 33, + type: "PipeWire:Interface:Client", + info: { props: { "application.name": "WirePlumber", "application.process.binary": "wireplumber" } }, + }, + { + id: 75, + type: "PipeWire:Interface:Client", + info: { + props: { + "application.name": "Firefox", + "application.process.binary": "firefox", + "application.process.id": 4242, + "pipewire.access.portal.app_id": "org.mozilla.firefox", + }, + }, + }, + { + id: 90, + type: "PipeWire:Interface:Client", + info: { props: { "application.name": "xdg-desktop-portal-wlr", "application.process.id": 1500 } }, + }, + { + id: 91, + type: "PipeWire:Interface:Client", + info: { + props: { "application.name": "OBS Studio", "application.process.binary": "obs", "application.process.id": 5100 }, + }, + }, + { + id: 45, + type: "PipeWire:Interface:Node", + info: { + state: "running", + props: { + "media.class": "Video/Source", + "node.name": "v4l2_input.pci-0000_00_14.0-usb-0_6_1.0", + "node.description": "Integrated Camera (V4L2)", + "device.api": "v4l2", + "client.id": 33, + }, + }, + }, + { + id: 56, + type: "PipeWire:Interface:Node", + info: { props: { "media.class": "Audio/Source", "node.description": "Built-in Audio Analog Stereo" } }, + }, + { + id: 57, + type: "PipeWire:Interface:Node", + info: { props: { "media.class": "Audio/Sink", "node.description": "Speakers" } }, + }, + { + id: 79, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Video", "client.id": 75 } }, + }, + { + id: 82, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Audio", "client.id": 75 } }, + }, + { + id: 83, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Audio", "application.name": "cava" } }, + }, + { + id: 95, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Video/Source", "node.name": "xdpw_stream", "client.id": 90 } }, + }, + { + id: 96, + type: "PipeWire:Interface:Node", + info: { state: "running", props: { "media.class": "Stream/Input/Video", "node.name": "OBS", "client.id": 91 } }, + }, + { id: 100, type: "PipeWire:Interface:Link", info: { "output-node-id": 45, "input-node-id": 79, state: "active" } }, + { id: 101, type: "PipeWire:Interface:Link", info: { "output-node-id": 56, "input-node-id": 82, state: "active" } }, + { id: 102, type: "PipeWire:Interface:Link", info: { "output-node-id": 57, "input-node-id": 83, state: "active" } }, + { id: 103, type: "PipeWire:Interface:Link", info: { "output-node-id": 95, "input-node-id": 96, state: "active" } }, +]); + +Deno.test("parsePwDump finds camera, microphone and screen users", () => { + const uses = parsePwDump(PW); + assertEquals(uses.map((u) => [u.kind, u.app, u.pid, u.device]), [ + ["camera", "Firefox", 4242, "Integrated Camera (V4L2)"], + ["screen", "OBS Studio", 5100, "xdpw_stream"], + ["microphone", "Firefox", 4242, "Built-in Audio Analog Stereo"], + ]); + assertEquals(uses[0].appId, "org.mozilla.firefox"); + assertEquals(parsePwDump("not json"), []); +}); + +Deno.test("parsePwDump shows a running screencast even without a viewer", () => { + const dump = JSON.parse(PW).filter((o: { id: number }) => o.id !== 103); + const uses = parsePwDump(JSON.stringify(dump)); + const screen = uses.find((u) => u.kind === "screen")!; + assertEquals([screen.app, screen.pid], ["xdg-desktop-portal-wlr", 1500]); +}); + +Deno.test("parseSourceOutputs keeps mic recordings, drops monitors and level meters", () => { + const outputs = JSON.stringify([ + { + index: 42, + source: 56, + corked: false, + mute: false, + properties: { + "application.name": "Firefox", + "application.process.id": "4242", + "application.process.binary": "firefox", + }, + }, + { index: 43, source: 57, properties: { "application.name": "cava" } }, + { index: 44, source: 56, properties: { "media.name": "Peak detect", "application.process.binary": "pavucontrol" } }, + { + index: 45, + source: 56, + corked: true, + properties: { "application.name": "Discord", "application.process.id": "777" }, + }, + ]); + const sources = JSON.stringify([ + { index: 56, name: "alsa_input.pci-0000_00_1f.3.analog-stereo", description: "Built-in Audio", mute: true }, + { index: 57, name: "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor", description: "Monitor of Speakers" }, + ]); + const uses = parseSourceOutputs(outputs, sources); + assertEquals(uses.map((u) => [u.app, u.pid, u.streamIndex, u.paused]), [["Firefox", 4242, 42, false], [ + "Discord", + 777, + 45, + true, + ]]); + assertEquals(uses[0].sourceName, "alsa_input.pci-0000_00_1f.3.analog-stereo"); + assertEquals(uses[0].sourceMuted, true); + assertEquals(parseSourceOutputs("[]", ""), []); +}); + +Deno.test("fuser, ps and device holders", () => { + assertEquals(parseFuser(" 1180m 7777"), [1180, 7777]); + assertEquals(parseFuser(""), []); + const procs = parsePs( + " 1180 9000 ada pipewire /usr/bin/pipewire\n 7777 65 ada zoom /opt/zoom/zoom --x\n", + ); + assertEquals(procs.get(7777), { pid: 7777, elapsed: 65, user: "ada", comm: "zoom", args: "/opt/zoom/zoom --x" }); + const uses = deviceUses([{ device: "/dev/video0", pids: [1180, 7777] }], procs); + assertEquals(uses.map((u) => [u.app, u.pid, u.device]), [["Zoom", 7777, "/dev/video0"]]); +}); + +Deno.test("mergeUses keeps one row per app, filling blanks", () => { + const a: Use = { kind: "microphone", key: "m1", app: "Firefox", pid: 4242, via: "pulse", streamIndex: 42 }; + const b: Use = { + kind: "microphone", + key: "m2", + app: "Firefox", + pid: 4242, + via: "pipewire", + appId: "org.mozilla.firefox", + }; + const c: Use = { kind: "camera", key: "c", app: "Firefox", pid: 4242, via: "pipewire" }; + const merged = mergeUses([a], [b, c]); + assertEquals(merged.length, 2); + assertEquals([merged[0].via, merged[0].streamIndex, merged[0].appId, merged[0].key], [ + "pulse", + 42, + "org.mozilla.firefox", + "microphone:4242", + ]); +}); + +Deno.test("GeoClue client listing", () => { + assertEquals( + parseGeoclueClients( + "/org/freedesktop/GeoClue2\n/org/freedesktop/GeoClue2/Client\n/org/freedesktop/GeoClue2/Client/3\n", + ), + ["/org/freedesktop/GeoClue2/Client/3"], + ); + assertEquals(parseGeoclueClients("└─ /org/freedesktop/GeoClue2/Client/1"), ["/org/freedesktop/GeoClue2/Client/1"]); + assertEquals(busValue('{"type":"b","data":true}'), true); + assertEquals(busValue("nope"), undefined); +}); + +Deno.test("words", () => { + const u = (kind: Use["kind"], paused = false): Use => ({ kind, key: kind, app: "x", via: "pipewire", paused }); + assertEquals(subtitle([]), null); + assertEquals(subtitle([u("camera")]), "Camera in use"); + assertEquals(subtitle([u("camera"), u("microphone")]), "Camera and microphone in use"); + assertEquals(subtitle([u("screen")]), "Screen shared"); + assertEquals(subtitle([u("microphone"), u("screen")]), "Microphone in use, screen shared"); + assertEquals(subtitle([u("microphone", true)]), null); + assertEquals(since(42), "42 s"); + assertEquals(since(3900), "1 h 05 min"); + assertEquals(prettyApp("google-chrome"), "Google Chrome"); + assertEquals(prettyApp("OBS"), "OBS"); + assert(since(90000).startsWith("1 d")); +}); diff --git a/extensions/qr-codes/backslash.toml b/extensions/qr-codes/backslash.toml new file mode 100644 index 0000000..9fdbfe7 --- /dev/null +++ b/extensions/qr-codes/backslash.toml @@ -0,0 +1,86 @@ +[extension] +id = "qr-codes" +title = "QR Codes" +description = "Turn text, links and Wi-Fi networks into QR codes, and scan the ones on your screen" +version = "1.0.0" +author = "Backslash" +icon = "bs-qr" +license = "MIT" + +# QR codes are encoded by the extension itself (qr.ts, no program needed) and +# cached as PNG in ~/.cache/backslash/qr; saved ones go to ~/Pictures/QR Codes. +# Scanning runs in the background (systemd-run) once the launcher is hidden: +# the first area capture tool installed (grim + slurp, gnome-screenshot, +# spectacle, maim, scrot or ImageMagick's import), then zbarimg (zbar). Here +# they are only started to check that they are installed. wl-copy / wl-paste +# and xclip copy and read pictures. +[permissions] +run = [ + "systemd-run", + "zbarimg", + "grim", + "slurp", + "gnome-screenshot", + "spectacle", + "maim", + "scrot", + "import", + "wl-copy", + "wl-paste", + "xclip", +] +read = ["~/.cache/backslash/qr", "~/Pictures", "~/Images", "~/.config/user-dirs.dirs", "/etc/os-release"] +write = ["~/.cache/backslash/qr", "~/Pictures", "~/Images"] +env = [ + "HOME", + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] +clipboard-read = true + +[[commands]] +name = "qr-codes" +title = "QR Codes" +description = "Type a text or link to get its QR code; scan, Wi-Fi codes and history" +icon = "bs-qr" +keywords = ["qr", "qr code", "qrcode", "code qr", "generate", "générer", "barcode", "code-barres", "wifi", "partager"] + +[[commands]] +name = "from-clipboard" +title = "QR Code from Clipboard" +description = "The copied text or link as a QR code, to open it on your phone" +icon = "bs-qr" +keywords = ["qr", "clipboard", "presse-papiers", "phone", "téléphone", "share", "partager", "link"] + +[[commands]] +name = "scan-screen" +title = "Scan QR Code on Screen" +description = "Select a QR code anywhere on the screen: its content is copied" +icon = "bs-scan" +mode = "no-view" +keywords = ["qr", "scan", "scanner", "read", "lire", "decode", "décoder", "screen", "écran"] + +[[commands]] +name = "scan-clipboard" +title = "Scan QR Code in Clipboard Image" +description = "Reads the QR code of a copied picture or screenshot" +icon = "bs-image" +keywords = ["qr", "scan", "clipboard", "image", "picture", "lire", "décoder", "screenshot"] + +[[preferences]] +name = "ecc" +title = "Error correction" +description = "L (7 %), M (15 %, default), Q (25 %) or H (30 %): higher survives damage, makes denser codes" +default = "M" + +[[preferences]] +name = "openLinks" +title = "Open scanned links" +description = "Opens http and https links in the browser right after scanning" +type = "boolean" +default = false diff --git a/extensions/qr-codes/commands/from-clipboard.ts b/extensions/qr-codes/commands/from-clipboard.ts new file mode 100644 index 0000000..545468b --- /dev/null +++ b/extensions/qr-codes/commands/from-clipboard.ts @@ -0,0 +1,14 @@ +import { clipboard, type Context, Icons, list } from "@backslash/api"; +import { eccOf, qrItem } from "../ui.ts"; + +// The clipboard's text as a QR code, e.g. to send a link to a phone. +export default (ctx: Context) => + list({ + placeholder: "Filter…", + empty: { title: "The clipboard has no text", description: "Copy a link or a text first", icon: Icons.qr }, + status: `Level ${eccOf(ctx)} · scan it with your phone`, + async items() { + const text = await clipboard.read(); + return text.trim() ? [await qrItem(text, eccOf(ctx), { id: "clipboard", subtitle: "From the clipboard" })] : []; + }, + }); diff --git a/extensions/qr-codes/commands/qr-codes.ts b/extensions/qr-codes/commands/qr-codes.ts new file mode 100644 index 0000000..a0740cf --- /dev/null +++ b/extensions/qr-codes/commands/qr-codes.ts @@ -0,0 +1,59 @@ +import { action, clipboard, type Context, Icons, type Item, list, refresh } from "@backslash/api"; +import { history, importScans } from "../system.ts"; +import { eccOf, entryItem, qrItem, scanItem, wifiForm } from "../ui.ts"; +import { scanClipboardImage } from "./scan-clipboard.ts"; + +export default (ctx: Context) => { + const ecc = eccOf(ctx); + let count = 0; + return list({ + placeholder: "Text or link to turn into a QR code…", + status: (items) => + items.some((i) => i.id?.startsWith("qr:")) + ? `Level ${ecc} · Ctrl+Shift+C copies the picture` + : `${count} in history`, + async search(query) { + const text = query.trim(); + if (text) return [await qrItem(query, ecc, { section: "QR Code" })]; + await importScans(); + const past = await history(); + count = past.length; + const clip = await clipboard.read().catch(() => ""); + const items: Item[] = []; + if (clip.trim()) { + items.push(await qrItem(clip, ecc, { id: "clipboard", section: "From the Clipboard", subtitle: "Clipboard" })); + } + items.push( + scanItem(ctx), + { + id: "scan-clipboard", + title: "Scan Image in Clipboard", + subtitle: "A copied screenshot or picture with a QR code", + icon: { source: Icons.image, tint: "blue" }, + section: "Scan", + actions: [action("Scan Image in Clipboard", () => scanClipboardImage(ecc), { icon: Icons.image })], + }, + { + id: "wifi", + title: "Wi-Fi QR Code…", + subtitle: "Guests scan it to join your network", + icon: { source: Icons.wifi, tint: "teal" }, + section: "Create", + actions: [action("Create Wi-Fi QR Code", () => wifiForm(ecc), { icon: Icons.wifi })], + }, + ...(await Promise.all(past.map((e) => entryItem(e, ecc)))), + ); + if (!past.length) { + items.push({ + id: "tip", + title: "Type a text or paste a link", + subtitle: "Its QR code appears here", + icon: { source: Icons.info, tint: "gray" }, + section: "Create", + actions: [action("Refresh", () => refresh(), { icon: Icons.refresh })], + }); + } + return items; + }, + }); +}; diff --git a/extensions/qr-codes/commands/scan-clipboard.ts b/extensions/qr-codes/commands/scan-clipboard.ts new file mode 100644 index 0000000..b99c52e --- /dev/null +++ b/extensions/qr-codes/commands/scan-clipboard.ts @@ -0,0 +1,55 @@ +import { type Context, detail, Icons, list, type ListSpec, meta } from "@backslash/api"; +import { capturePackages, installMarkdown } from "../lib.ts"; +import { type Ecc } from "../qr.ts"; +import { clipboardImage, decodeFile, family, MissingTool, remember, session } from "../system.ts"; +import { eccOf, entryItem } from "../ui.ts"; + +/** Decodes the picture in the clipboard and lists what it holds. */ +export async function scanClipboardImage(ecc: Ecc): Promise<ListSpec | ReturnType<typeof detail>> { + const file = await clipboardImage(); + if (!file) { + return list({ + title: "Scan Image in Clipboard", + items: [{ + id: "none", + title: "No picture in the clipboard", + subtitle: "Copy a screenshot or an image first", + icon: { source: Icons.image, tint: "gray" }, + detail: { + markdown: + "Copy a picture (or take a screenshot to the clipboard), then run this again. Needs **wl-clipboard** on Wayland or **xclip** on X11.", + }, + }], + }); + } + let found: string[]; + try { + found = await decodeFile(file); + } catch (e) { + if (!(e instanceof MissingTool)) throw e; + return detail({ + title: "Scan Image in Clipboard", + markdown: installMarkdown( + `**${e.message}.** Install it, then try again.`, + e.packages.length ? e.packages : capturePackages(session()), + await family(), + ), + }); + } + if (!found.length) { + return list({ + title: "Scan Image in Clipboard", + items: [{ + id: "none", + title: "No QR code in this picture", + icon: { source: Icons.scan, tint: "gray" }, + detail: { image: file, metadata: [meta.text("Tip", "Crop closer to the code, with a little margin")] }, + }], + }); + } + const entries = found.map((text) => ({ kind: "scanned" as const, text, at: Date.now(), image: file })); + await remember(entries.map(({ image: _, ...e }) => e)); + return list({ title: "Scanned", items: await Promise.all(entries.map((e) => entryItem(e, ecc))) }); +} + +export default (ctx: Context) => scanClipboardImage(eccOf(ctx)); diff --git a/extensions/qr-codes/commands/scan-screen.ts b/extensions/qr-codes/commands/scan-screen.ts new file mode 100644 index 0000000..4024bf2 --- /dev/null +++ b/extensions/qr-codes/commands/scan-screen.ts @@ -0,0 +1,15 @@ +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 notifies. +export default run(async ({ preferences }) => { + try { + await scanScreen({ openLinks: !!(preferences as { openLinks?: boolean }).openLinks }); + } 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/qr-codes/lib.ts b/extensions/qr-codes/lib.ts new file mode 100644 index 0000000..3ad49d2 --- /dev/null +++ b/extensions/qr-codes/lib.ts @@ -0,0 +1,446 @@ +// What a QR code holds, Wi-Fi payloads, history, and the background scan +// script. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Payloads + +export type PayloadKind = "url" | "wifi" | "email" | "phone" | "sms" | "geo" | "contact" | "otp" | "event" | "text"; + +export interface Payload { + kind: PayloadKind; + /** What to show as the title. */ + title: string; + /** Lines for the detail panel. */ + fields: { label: string; value: string; secret?: boolean }[]; + /** Something to open: a URL, mailto:, tel:, geo… */ + open?: string; + /** Contains a secret (password, OTP seed): copy concealed, don't keep the text in history. */ + secret?: boolean; +} + +/** Splits "a;b\;c;" on unescaped `sep`, unescaping \\, \;, \,, \: and \". */ +function splitEscaped(text: string, sep: string): string[] { + const out: string[] = []; + let cur = ""; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === "\\" && i + 1 < text.length) cur += text[++i]; + else if (c === sep) { + out.push(cur); + cur = ""; + } else cur += c; + } + out.push(cur); + return out; +} + +export interface Wifi { + ssid: string; + password?: string; + security: "WPA" | "WEP" | "nopass" | string; + hidden?: boolean; +} + +/** "WIFI:T:WPA;S:My Net;P:p\;ss;H:true;;" */ +export function parseWifi(text: string): Wifi | undefined { + if (!/^WIFI:/i.test(text)) return undefined; + const fields: Record<string, string> = {}; + for (const part of splitEscaped(text.slice(5), ";")) { + const m = part.match(/^([A-Za-z]+):([\s\S]*)$/); + if (m) fields[m[1].toUpperCase()] = m[2]; + } + if (fields.S === undefined) return undefined; + return { + ssid: fields.S, + password: fields.P || undefined, + security: fields.T || (fields.P ? "WPA" : "nopass"), + ...(/^true$/i.test(fields.H ?? "") ? { hidden: true } : {}), + }; +} + +const escapeWifi = (s: string) => s.replace(/([\\;,:"])/g, "\\$1"); + +export function wifiPayload(w: Wifi): string { + const security = w.password ? (w.security === "nopass" ? "WPA" : w.security) : "nopass"; + return `WIFI:T:${security};S:${escapeWifi(w.ssid)};${w.password ? `P:${escapeWifi(w.password)};` : ""}${ + w.hidden ? "H:true;" : "" + };`; +} + +/** Reads a scanned or typed text as what it is. */ +export function parsePayload(raw: string): Payload { + const text = raw.trim(); + const wifi = parseWifi(text); + if (wifi) { + return { + kind: "wifi", + title: wifi.ssid, + fields: [ + { label: "Network", value: wifi.ssid }, + { label: "Security", value: wifi.security === "nopass" ? "None (open)" : wifi.security }, + ...(wifi.password ? [{ label: "Password", value: wifi.password, secret: true }] : []), + ...(wifi.hidden ? [{ label: "Hidden", value: "Yes" }] : []), + ], + secret: !!wifi.password, + }; + } + if (/^otpauth(-migration)?:\/\//i.test(text)) { + let label = "One-time password"; + const fields: Payload["fields"] = []; + try { + const u = new URL(text); + label = decodeURIComponent(u.pathname.replace(/^\/+/, "")) || label; + const issuer = u.searchParams.get("issuer"); + if (issuer) fields.push({ label: "Issuer", value: issuer }); + fields.push({ label: "Type", value: u.host.toUpperCase() || "TOTP" }); + const secret = u.searchParams.get("secret"); + if (secret) fields.push({ label: "Secret", value: secret, secret: true }); + } catch { + // Keep the defaults. + } + return { kind: "otp", title: label, fields, secret: true }; + } + if (/^https?:\/\/\S+$/i.test(text)) { + let host = text; + try { + host = new URL(text).hostname; + } catch { + // Not a valid URL after all; shown as is. + } + return { kind: "url", title: text, fields: [{ label: "Site", value: host }], open: text }; + } + if (/^(?:www\.)[^\s/]+\.[a-z]{2,}\S*$/i.test(text)) { + return { + kind: "url", + title: text, + fields: [{ label: "Site", value: text.split("/")[0] }], + open: `https://${text}`, + }; + } + const mail = text.match(/^(?:mailto:|MATMSG:TO:)([^?;\s]+)/i); + if (mail) { + const fields = [{ label: "To", value: mail[1] }]; + const sub = text.match(/[?&]subject=([^&]*)/i)?.[1] ?? text.match(/;SUB:([^;]*)/i)?.[1]; + if (sub) fields.push({ label: "Subject", value: safeDecode(sub) }); + return { kind: "email", title: mail[1], fields, open: /^mailto:/i.test(text) ? text : `mailto:${mail[1]}` }; + } + if (/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(text)) { + return { kind: "email", title: text, fields: [{ label: "To", value: text }], open: `mailto:${text}` }; + } + const tel = text.match(/^tel:(\+?[\d\s().-]+)$/i); + if (tel) return { kind: "phone", title: tel[1], fields: [{ label: "Number", value: tel[1] }], open: text }; + const sms = text.match(/^(?:sms|smsto):(\+?[\d\s().-]+)(?::|\?body=)?([\s\S]*)$/i); + if (sms) { + return { + kind: "sms", + title: sms[1], + fields: [ + { label: "Number", value: sms[1] }, + ...(sms[2] ? [{ label: "Message", value: safeDecode(sms[2]) }] : []), + ], + open: `sms:${sms[1]}`, + }; + } + const geo = text.match(/^geo:(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/i); + if (geo) { + return { + kind: "geo", + title: `${geo[1]}, ${geo[2]}`, + fields: [{ label: "Latitude", value: geo[1] }, { label: "Longitude", value: geo[2] }], + open: `https://www.openstreetmap.org/?mlat=${geo[1]}&mlon=${geo[2]}#map=15/${geo[1]}/${geo[2]}`, + }; + } + if (/^BEGIN:VCARD/i.test(text) || /^MECARD:/i.test(text)) { + const v = (key: string) => + text.match(new RegExp(`^${key}(?:;[^:\\n]*)?:(.*)$`, "im"))?.[1]?.trim() ?? + text.match(new RegExp(`[:;]${key}:([^;]*)`, "i"))?.[1]?.trim(); + const name = v("FN") ?? v("N")?.split(/[;,]/).filter(Boolean).reverse().join(" ") ?? "Contact"; + const fields = [ + ["Name", name], + ["Phone", v("TEL")], + ["Email", v("EMAIL")], + ["Organization", v("ORG")], + ["Website", v("URL")], + ].filter((f): f is [string, string] => !!f[1]).map(([label, value]) => ({ label, value })); + return { kind: "contact", title: name, fields }; + } + if (/^BEGIN:VEVENT/im.test(text) || /^BEGIN:VCALENDAR/i.test(text)) { + const summary = text.match(/^SUMMARY:(.*)$/im)?.[1]?.trim() ?? "Event"; + const start = text.match(/^DTSTART[^:]*:(.*)$/im)?.[1]?.trim(); + return { + kind: "event", + title: summary, + fields: [{ label: "Event", value: summary }, ...(start ? [{ label: "Starts", value: start }] : [])], + }; + } + const firstLine = text.split("\n")[0]; + return { + kind: "text", + title: firstLine.length > 80 ? `${firstLine.slice(0, 80)}…` : firstLine || "(empty)", + fields: [{ label: "Length", value: `${[...text].length} characters` }], + }; +} + +function safeDecode(s: string): string { + try { + return decodeURIComponent(s.replace(/\+/g, " ")); + } catch { + return s; + } +} + +// --------------------------------------------------------------------------- +// History + +export interface Entry { + kind: "generated" | "scanned"; + text: string; + /** Unix ms. */ + at: number; + /** The picture a scan was read from. */ + image?: string; +} + +/** Newest first, one entry per kind and text, at most `max`. */ +export function mergeHistory(history: Entry[], added: Entry[], max = 50): Entry[] { + const all = [...added, ...history].sort((a, b) => b.at - a.at); + const seen = new Set<string>(); + return all.filter((e) => { + const key = `${e.kind}\n${e.text}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).slice(0, max); +} + +/** Files "1730000000123456789.txt" written by the scan script → entries. */ +export function scanEntry(name: string, text: string): Entry | undefined { + const m = name.match(/^(\d{10,19})\.txt$/); + if (!m || !text.trim()) return undefined; + const at = m[1].length > 13 ? Number(m[1].slice(0, 13)) : Number(m[1]) * (m[1].length <= 10 ? 1000 : 1); + return { kind: "scanned", text: text.replace(/\n$/, ""), at }; +} + +// --------------------------------------------------------------------------- +// Files + +/** A short stable name for the picture of a text: FNV-1a, 64 bits as two 32-bit halves. */ +export function fileKey(text: string, ecc: string): string { + let h1 = 0x811c9dc5; + let h2 = 0x01000193; + for (const b of new TextEncoder().encode(`${ecc}\n${text}`)) { + h1 = Math.imul(h1 ^ b, 0x01000193) >>> 0; + h2 = Math.imul(h2 ^ b, 0x5bd1e995) >>> 0; + } + return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); +} + +/** "QR https example.com 2026-10-28 14.05.12.png", safe for every file system. */ +export function saveName(text: string, date: Date, ext = "png"): string { + const words = text.replace(/^[a-z]+:\/*/i, "").replace(/[^\p{L}\p{N}]+/gu, " ").trim().slice(0, 40).trim(); + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}.${ + pad(date.getMinutes()) + }.${pad(date.getSeconds())}`; + return `QR ${words ? `${words} ` : ""}${stamp}.${ext}`; +} + +/** The pixels per module so that the picture is about `target` pixels wide. */ +export const scaleFor = (size: number, target = 600) => Math.max(4, Math.round(target / (size + 8))); + +/** `~/.config/user-dirs.dirs` → the pictures folder. */ +export function picturesDir(userDirs: string, home: string): string { + const m = userDirs.match(/^XDG_PICTURES_DIR="([^"]*)"/m); + return m ? m[1].replace(/^\$HOME/, home) : `${home}/Pictures`; +} + +// --------------------------------------------------------------------------- +// Sessions and the scan script + +export type Desktop = "gnome" | "kde" | "hyprland" | "sway" | "other"; + +export interface Session { + wayland: boolean; + desktop: Desktop; +} + +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", +] as const; + +export type Env = Partial<Record<string, string>>; + +export function detectSession(env: Env): Session { + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const current = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const desktop: Desktop = env.HYPRLAND_INSTANCE_SIGNATURE || current.includes("hyprland") + ? "hyprland" + : env.SWAYSOCK || current.includes("sway") + ? "sway" + : current.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)) + ? "gnome" + : current.includes("kde") + ? "kde" + : "other"; + return { wayland, desktop }; +} + +export type 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"]; +} + +/** 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}`; + } +} + +export interface ScanOptions { + tool: ShotTool; + /** Where the capture and results go: ~/.cache/backslash/qr. */ + cache: string; + wayland: boolean; + /** Open http(s) links right away. */ + openLinks?: boolean; + copy?: boolean; +} + +/** + * Captures an area, decodes it with zbarimg, leaves the result in + * `<cache>/scans/<ns>.txt` (read back by the extension), copies it (unless it + * holds a password or OTP seed) and notifies. + */ +export function scanScript(o: ScanOptions): string { + const shot = `${o.cache}/scan.png`; + const copyCmd = o.wayland + ? `{ printf %s "$out" | wl-copy || printf %s "$out" | xclip -selection clipboard; } 2>/dev/null` + : `{ printf %s "$out" | xclip -selection clipboard || printf %s "$out" | wl-copy; } 2>/dev/null`; + return [ + "sleep 0.4", + `mkdir -p ${q(`${o.cache}/scans`)}`, + `rm -f ${q(shot)}`, + `${areaCommand(o.tool, shot)} || exit 0`, + `[ -s ${q(shot)} ] || exit 0`, + `if out=$(zbarimg --raw -q ${q(shot)} 2>/dev/null) && [ -n "$out" ]; then`, + ` id=$(date +%s%N)`, + ` printf '%s\\n' "$out" > ${q(`${o.cache}/scans/`)}"$id.txt"`, + ` mv -f ${q(shot)} ${q(`${o.cache}/scans/`)}"$id.png"`, + ` case "$out" in WIFI:*P:*|otpauth*) note='Open QR Codes to see it' ;; *)`, + o.copy === false + ? " note='Open QR Codes to see it'" + : ` if ${copyCmd}; then note='Copied to the clipboard'; else note=''; fi`, + " ;; esac", + ` notify-send -a Backslash -i ${ + q(`${o.cache}/scans/`) + }"$id.png" 'QR code scanned' "$(printf %s "$out" | head -c 160 | tr -d '<>&')\${note:+ · $note}" 2>/dev/null`, + ...(o.openLinks ? [` case "$out" in http://*|https://*) xdg-open "$out" >/dev/null 2>&1 & ;; esac`] : []), + "else", + ` notify-send -a Backslash 'No QR code found' 'Select the whole code with a little margin around it' 2>/dev/null`, + "fi", + "true", + ].join("\n"); +} + +/** `systemd-run` arguments to run a script in the background, outside the launcher. */ +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: scan a QR code", + "--collect", + // wl-copy and xclip stay in the background to serve the clipboard. + "--property=KillMode=process", + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + "/bin/sh", + "-c", + script, + ]; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const PACKAGES: Record<string, Partial<Record<Family, string>>> = { + zbarimg: { arch: "zbar", debian: "zbar-tools", fedora: "zbar", suse: "zbar" }, + spectacle: { debian: "kde-spectacle" }, + import: { arch: "imagemagick", debian: "imagemagick", fedora: "ImageMagick", suse: "ImageMagick" }, + "wl-copy": { arch: "wl-clipboard", debian: "wl-clipboard", fedora: "wl-clipboard", suse: "wl-clipboard" }, +}; + +const MANAGERS: Record<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +const FAMILY_NAMES: Record<Family, string> = { + 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") + }`; +} + +/** What to install to capture an area in this session. */ +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"]; +} diff --git a/extensions/qr-codes/lib_test.ts b/extensions/qr-codes/lib_test.ts new file mode 100644 index 0000000..e54243c --- /dev/null +++ b/extensions/qr-codes/lib_test.ts @@ -0,0 +1,133 @@ +import { assert, assertEquals, assertMatch, assertNotEquals } from "@std/assert"; +import { + areaCommand, + areaTools, + capturePackages, + detectSession, + fileKey, + installCommand, + mergeHistory, + osFamily, + parsePayload, + parseWifi, + picturesDir, + saveName, + scaleFor, + scanEntry, + scanScript, + systemdRunArgs, + wifiPayload, +} from "./lib.ts"; + +Deno.test("Wi-Fi payloads round-trip, with escaping", () => { + const w = { ssid: 'Café; "Wi:Fi"', password: "p\\a;ss,word", security: "WPA", hidden: true }; + const text = wifiPayload(w); + assertEquals(text, 'WIFI:T:WPA;S:Café\\; \\"Wi\\:Fi\\";P:p\\\\a\\;ss\\,word;H:true;;'); + assertEquals(parseWifi(text), w); + assertEquals(wifiPayload({ ssid: "Open", security: "WPA" }), "WIFI:T:nopass;S:Open;;"); + assertEquals(parseWifi("WIFI:S:Home;T:WPA;P:secret;;"), { ssid: "Home", password: "secret", security: "WPA" }); + assertEquals(parseWifi("WIFI:T:WPA;;"), undefined); +}); + +Deno.test("what a code holds", () => { + const wifi = parsePayload("WIFI:T:WPA;S:Home;P:hunter2;;"); + assertEquals([wifi.kind, wifi.title, wifi.secret], ["wifi", "Home", true]); + assertEquals(wifi.fields.find((f) => f.label === "Password"), { label: "Password", value: "hunter2", secret: true }); + + const otp = parsePayload("otpauth://totp/GitHub:ada?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"); + assertEquals([otp.kind, otp.title, otp.secret], ["otp", "GitHub:ada", true]); + assertEquals(otp.fields.map((f) => f.label), ["Issuer", "Type", "Secret"]); + + const url = parsePayload("https://example.com/menu?table=4"); + assertEquals([url.kind, url.open, url.fields[0].value], ["url", "https://example.com/menu?table=4", "example.com"]); + assertEquals(parsePayload("www.example.com/x").open, "https://www.example.com/x"); + + const mail = parsePayload("mailto:ada@example.com?subject=Hello%20there"); + assertEquals([mail.kind, mail.fields[1].value], ["email", "Hello there"]); + assertEquals(parsePayload("MATMSG:TO:ada@example.com;SUB:Hi;BODY:x;;").open, "mailto:ada@example.com"); + assertEquals(parsePayload("tel:+33 1 23 45 67 89").kind, "phone"); + assertEquals(parsePayload("SMSTO:+15551234:Running late").fields[1].value, "Running late"); + const geo = parsePayload("geo:48.8584,2.2945"); + assertEquals([geo.kind, geo.title], ["geo", "48.8584, 2.2945"]); + assertMatch(geo.open!, /openstreetmap\.org\/\?mlat=48\.8584&mlon=2\.2945/); + + const card = parsePayload( + "BEGIN:VCARD\nVERSION:3.0\nN:Lovelace;Ada\nFN:Ada Lovelace\nTEL;TYPE=cell:+44 20 1234\nEMAIL:ada@example.com\nEND:VCARD", + ); + assertEquals(card.title, "Ada Lovelace"); + assertEquals(card.fields.map((f) => f.label), ["Name", "Phone", "Email"]); + assertEquals(parsePayload("MECARD:N:Turing,Alan;TEL:0123;;").title, "Alan Turing"); + assertEquals(parsePayload("BEGIN:VEVENT\nSUMMARY:Launch\nDTSTART:20261101T090000Z\nEND:VEVENT").title, "Launch"); + const text = parsePayload("Hello\nworld"); + assertEquals([text.kind, text.title], ["text", "Hello"]); +}); + +Deno.test("history", () => { + const h = mergeHistory( + [{ kind: "generated", text: "a", at: 1 }, { kind: "scanned", text: "b", at: 2 }], + [{ kind: "generated", text: "a", at: 5 }, { kind: "scanned", text: "a", at: 3 }], + ); + assertEquals(h.map((e) => [e.kind, e.text, e.at]), [["generated", "a", 5], ["scanned", "a", 3], ["scanned", "b", 2]]); + assertEquals( + mergeHistory([], Array.from({ length: 80 }, (_, i) => ({ kind: "generated" as const, text: `${i}`, at: i })), 50) + .length, + 50, + ); + assertEquals(scanEntry("1730000000123456789.txt", "https://x.org\n"), { + kind: "scanned", + text: "https://x.org", + at: 1730000000123, + }); + assertEquals(scanEntry("1730000000.txt", "x")?.at, 1730000000000); + assertEquals(scanEntry("notes.txt", "x"), undefined); + assertEquals(scanEntry("1730000000.txt", " \n"), undefined); +}); + +Deno.test("files", () => { + assertEquals(fileKey("hello", "M"), fileKey("hello", "M")); + assertNotEquals(fileKey("hello", "M"), fileKey("hello", "H")); + assertMatch(fileKey("x", "L"), /^[0-9a-f]{16}$/); + assertEquals( + saveName("https://example.com/a?b", new Date(2026, 9, 28, 14, 5, 9)), + "QR example com a b 2026-10-28 14.05.09.png", + ); + assertEquals( + saveName("WIFI:T:WPA;S:Home;;", new Date(2026, 0, 2, 3, 4, 5), "svg"), + "QR T WPA S Home 2026-01-02 03.04.05.svg", + ); + assertEquals(scaleFor(21), 21); + assertEquals(scaleFor(177), 4); + assertEquals(picturesDir('XDG_PICTURES_DIR="$HOME/Images"\n', "/home/ada"), "/home/ada/Images"); + assertEquals(picturesDir("", "/home/ada"), "/home/ada/Pictures"); +}); + +Deno.test("capture tools and the scan script", () => { + const sway = detectSession({ XDG_SESSION_TYPE: "wayland", SWAYSOCK: "/run/sway.sock" }); + assertEquals(areaTools(sway)[0], "grim"); + assertEquals(areaTools(detectSession({ XDG_SESSION_TYPE: "wayland", XDG_CURRENT_DESKTOP: "GNOME" })), [ + "gnome-screenshot", + ]); + assertEquals(areaTools(detectSession({ XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }))[0], "maim"); + assertEquals(capturePackages(sway), ["grim", "slurp"]); + assertEquals(areaCommand("spectacle", "/tmp/a b.png"), "spectacle -b -n -r -o '/tmp/a b.png'"); + + const s = scanScript({ tool: "grim", cache: "/home/ada/.cache/backslash/qr", wayland: true, openLinks: true }); + assert(s.includes(`geometry=$(slurp -d) || exit 0; grim -g "$geometry" '/home/ada/.cache/backslash/qr/scan.png'`)); + assert(s.includes("zbarimg --raw -q '/home/ada/.cache/backslash/qr/scan.png'")); + assert(s.includes(`printf '%s\\n' "$out" > '/home/ada/.cache/backslash/qr/scans/'"$id.txt"`)); + assert(s.includes("WIFI:*P:*|otpauth*) note="), "secrets are not copied"); + assert(s.includes("wl-copy")); + assert(s.includes("xdg-open")); + assert(!scanScript({ tool: "maim", cache: "/c", wayland: false }).includes("xdg-open")); + + const args = systemdRunArgs("backslash-qr-scan-1", "true", { DISPLAY: ":0", WAYLAND_DISPLAY: undefined }); + assertEquals(args.slice(0, 3), ["--user", "--quiet", "--unit=backslash-qr-scan-1"]); + assert(args.includes("--setenv=DISPLAY=:0") && !args.some((a) => a.startsWith("--setenv=WAYLAND"))); + assertEquals(args.slice(-3), ["/bin/sh", "-c", "true"]); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily("ID=arch\n"), "arch"); + assertEquals(installCommand(["zbarimg"], "debian"), "sudo apt install zbar-tools"); + assertEquals(installCommand(["zbarimg", "grim", "slurp"], "fedora"), "sudo dnf install zbar grim slurp"); +}); diff --git a/extensions/qr-codes/png.ts b/extensions/qr-codes/png.ts new file mode 100644 index 0000000..44fa494 --- /dev/null +++ b/extensions/qr-codes/png.ts @@ -0,0 +1,100 @@ +// A minimal PNG writer for QR codes: 8-bit grayscale, zlib-compressed with +// CompressionStream (deflate = zlib format, as PNG wants). + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(bytes: Uint8Array, crc = 0xffffffff): number { + for (const b of bytes) crc = CRC_TABLE[(crc ^ b) & 0xff] ^ (crc >>> 8); + return crc; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const view = new DataView(out.buffer); + view.setUint32(0, data.length); + const typeBytes = new TextEncoder().encode(type); + out.set(typeBytes, 4); + out.set(data, 8); + view.setUint32(8 + data.length, (crc32(out.subarray(4, 8 + data.length)) ^ 0xffffffff) >>> 0); + return out; +} + +async function zlib(data: Uint8Array): Promise<Uint8Array> { + const stream = new Blob([data as BlobPart]).stream().pipeThrough(new CompressionStream("deflate")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +export interface PngOptions { + /** Pixels per module. */ + scale?: number; + /** Light modules around the code (the standard asks for 4). */ + border?: number; + /** Gray levels, 0 to 255. */ + dark?: number; + light?: number; +} + +/** Encodes modules[y][x] (true = dark) as a PNG. */ +export async function png(modules: boolean[][], o: PngOptions = {}): Promise<Uint8Array> { + const scale = Math.max(1, Math.round(o.scale ?? 10)); + const border = Math.max(0, Math.round(o.border ?? 4)); + const n = modules.length; + const side = (n + border * 2) * scale; + const dark = o.dark ?? 0; + const light = o.light ?? 255; + const raw = new Uint8Array((side + 1) * side); + for (let y = 0; y < side; y++) { + const row = y * (side + 1); + raw[row] = 0; // filter: none + const my = Math.floor(y / scale) - border; + for (let x = 0; x < side; x++) { + const mx = Math.floor(x / scale) - border; + const on = my >= 0 && my < n && mx >= 0 && mx < n && modules[my][mx]; + raw[row + 1 + x] = on ? dark : light; + } + } + const ihdr = new Uint8Array(13); + const v = new DataView(ihdr.buffer); + v.setUint32(0, side); + v.setUint32(4, side); + ihdr[8] = 8; // bit depth + ihdr[9] = 0; // grayscale + const parts = [ + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", await zlib(raw)), + chunk("IEND", new Uint8Array()), + ]; + const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +/** The same code as an SVG document, one path, for "Save as SVG". */ +export function svg(modules: boolean[][], border = 4): string { + const n = modules.length; + const side = n + border * 2; + let d = ""; + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + if (!modules[y][x]) continue; + let w = 1; + while (x + w < n && modules[y][x + w]) w++; + d += `M${x + border} ${y + border}h${w}v1h-${w}z`; + x += w - 1; + } + } + return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="#fff"/><path fill="#000" d="${d}"/></svg>\n`; +} diff --git a/extensions/qr-codes/qr.ts b/extensions/qr-codes/qr.ts new file mode 100644 index 0000000..372777d --- /dev/null +++ b/extensions/qr-codes/qr.ts @@ -0,0 +1,788 @@ +// A QR Code encoder (ISO/IEC 18004), model 2, versions 1 to 40: numeric, +// alphanumeric and byte (UTF-8) modes, the four error correction levels and +// automatic mask selection. Checked against segno's output in qr_test.ts. + +export type Ecc = "L" | "M" | "Q" | "H"; + +const ECC_ORDER: Ecc[] = ["L", "M", "Q", "H"]; +/** The two format bits of each level. */ +const ECC_BITS: Record<Ecc, number> = { L: 1, M: 0, Q: 3, H: 2 }; + +// Index 0 is padding; versions 1 to 40. +const ECC_CODEWORDS_PER_BLOCK: Record<Ecc, number[]> = { + L: [ + -1, + 7, + 10, + 15, + 20, + 26, + 18, + 20, + 24, + 30, + 18, + 20, + 24, + 26, + 30, + 22, + 24, + 28, + 30, + 28, + 28, + 28, + 28, + 30, + 30, + 26, + 28, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + ], + M: [ + -1, + 10, + 16, + 26, + 18, + 24, + 16, + 18, + 22, + 22, + 26, + 30, + 22, + 22, + 24, + 24, + 28, + 28, + 26, + 26, + 26, + 26, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + 28, + ], + Q: [ + -1, + 13, + 22, + 18, + 26, + 18, + 24, + 18, + 22, + 20, + 24, + 28, + 26, + 24, + 20, + 30, + 24, + 28, + 28, + 26, + 30, + 28, + 30, + 30, + 30, + 30, + 28, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + ], + H: [ + -1, + 17, + 28, + 22, + 16, + 22, + 28, + 26, + 26, + 24, + 28, + 24, + 28, + 22, + 24, + 24, + 30, + 28, + 28, + 26, + 28, + 30, + 24, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + ], +}; + +const NUM_BLOCKS: Record<Ecc, number[]> = { + L: [ + -1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 4, + 4, + 4, + 4, + 4, + 6, + 6, + 6, + 6, + 7, + 8, + 8, + 9, + 9, + 10, + 12, + 12, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 19, + 20, + 21, + 22, + 24, + 25, + ], + M: [ + -1, + 1, + 1, + 1, + 2, + 2, + 4, + 4, + 4, + 5, + 5, + 5, + 8, + 9, + 9, + 10, + 10, + 11, + 13, + 14, + 16, + 17, + 17, + 18, + 20, + 21, + 23, + 25, + 26, + 28, + 29, + 31, + 33, + 35, + 37, + 38, + 40, + 43, + 45, + 47, + 49, + ], + Q: [ + -1, + 1, + 1, + 2, + 2, + 4, + 4, + 6, + 6, + 8, + 8, + 8, + 10, + 12, + 16, + 12, + 17, + 16, + 18, + 21, + 20, + 23, + 23, + 25, + 27, + 29, + 34, + 34, + 35, + 38, + 40, + 43, + 45, + 48, + 51, + 53, + 56, + 59, + 62, + 65, + 68, + ], + H: [ + -1, + 1, + 1, + 2, + 4, + 4, + 4, + 5, + 6, + 8, + 8, + 11, + 11, + 16, + 16, + 18, + 16, + 19, + 21, + 25, + 25, + 25, + 34, + 30, + 32, + 35, + 37, + 40, + 42, + 45, + 48, + 51, + 54, + 57, + 60, + 63, + 66, + 70, + 74, + 77, + 81, + ], +}; + +export type Mode = "numeric" | "alphanumeric" | "byte"; + +const MODE_BITS: Record<Mode, number> = { numeric: 0x1, alphanumeric: 0x2, byte: 0x4 }; +const ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + +/** Bits of the character count, by mode, for versions 1–9, 10–26 and 27–40. */ +function countBits(mode: Mode, version: number): number { + const i = version <= 9 ? 0 : version <= 26 ? 1 : 2; + return { numeric: [10, 12, 14], alphanumeric: [9, 11, 13], byte: [8, 16, 16] }[mode][i]; +} + +/** Modules available for data and error correction (everything but the function patterns). */ +function rawDataModules(version: number): number { + let result = (16 * version + 128) * version + 64; + if (version >= 2) { + const align = Math.floor(version / 7) + 2; + result -= (25 * align - 10) * align - 55; + if (version >= 7) result -= 36; + } + return result; +} + +/** Data codewords (8 bits) of a version and level. */ +export function dataCodewords(version: number, ecc: Ecc): number { + return Math.floor(rawDataModules(version) / 8) - ECC_CODEWORDS_PER_BLOCK[ecc][version] * NUM_BLOCKS[ecc][version]; +} + +// --------------------------------------------------------------------------- +// Reed–Solomon over GF(256), polynomial 0x11d + +function gfMultiply(x: number, y: number): number { + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + return z & 0xff; +} + +/** The generator polynomial of the given degree, highest coefficient (1) left out. */ +function rsDivisor(degree: number): number[] { + const result = new Array<number>(degree).fill(0); + result[degree - 1] = 1; + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = gfMultiply(result[j], root); + if (j + 1 < result.length) result[j] ^= result[j + 1]; + } + root = gfMultiply(root, 0x02); + } + return result; +} + +function rsRemainder(data: number[], divisor: number[]): number[] { + const result = divisor.map(() => 0); + for (const b of data) { + const factor = b ^ (result.shift() as number); + result.push(0); + divisor.forEach((coef, i) => (result[i] ^= gfMultiply(coef, factor))); + } + return result; +} + +// --------------------------------------------------------------------------- +// Data + +export function modeFor(text: string): Mode { + if (/^\d*$/.test(text)) return "numeric"; + if ([...text].every((c) => ALPHANUMERIC.includes(c))) return "alphanumeric"; + return "byte"; +} + +function pushBits(bits: number[], value: number, length: number) { + for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1); +} + +interface Segment { + mode: Mode; + count: number; + bits: number[]; +} + +function segment(text: string, mode: Mode): Segment { + const bits: number[] = []; + if (mode === "numeric") { + for (let i = 0; i < text.length; i += 3) { + const chunk = text.slice(i, i + 3); + pushBits(bits, Number(chunk), chunk.length * 3 + 1); + } + return { mode, count: text.length, bits }; + } + if (mode === "alphanumeric") { + for (let i = 0; i + 1 < text.length; i += 2) { + pushBits(bits, ALPHANUMERIC.indexOf(text[i]) * 45 + ALPHANUMERIC.indexOf(text[i + 1]), 11); + } + if (text.length % 2) pushBits(bits, ALPHANUMERIC.indexOf(text[text.length - 1]), 6); + return { mode, count: text.length, bits }; + } + const bytes = new TextEncoder().encode(text); + for (const b of bytes) pushBits(bits, b, 8); + return { mode, count: bytes.length, bits }; +} + +const segmentBits = (s: Segment, version: number) => 4 + countBits(s.mode, version) + s.bits.length; + +// --------------------------------------------------------------------------- +// The symbol + +export interface QrCode { + version: number; + ecc: Ecc; + mask: number; + mode: Mode; + size: number; + /** modules[y][x], true = dark. */ + modules: boolean[][]; +} + +export class TooLong extends Error {} + +export interface EncodeOptions { + ecc?: Ecc; + /** 0 to 7; chosen by penalty when left out. */ + mask?: number; + mode?: Mode; + minVersion?: number; + /** Raise the error correction level when it fits in the same version. Default true. */ + boost?: boolean; +} + +export function encode(text: string, o: EncodeOptions = {}): QrCode { + const mode = o.mode ?? modeFor(text); + const seg = segment(text, mode); + let ecc = o.ecc ?? "M"; + let version = Math.max(1, o.minVersion ?? 1); + for (;; version++) { + if (version > 40) throw new TooLong(`Too long for a QR code: ${new TextEncoder().encode(text).length} bytes`); + const bits = segmentBits(seg, version); + if (seg.count < 2 ** countBits(mode, version) && bits <= dataCodewords(version, ecc) * 8) break; + } + if (o.boost !== false) { + for (const higher of ECC_ORDER.slice(ECC_ORDER.indexOf(ecc) + 1)) { + if (segmentBits(seg, version) <= dataCodewords(version, higher) * 8) ecc = higher; + } + } + + // Header, data, terminator, byte alignment, pad bytes. + const capacity = dataCodewords(version, ecc) * 8; + const bits: number[] = []; + pushBits(bits, MODE_BITS[mode], 4); + pushBits(bits, seg.count, countBits(mode, version)); + bits.push(...seg.bits); + pushBits(bits, 0, Math.min(4, capacity - bits.length)); + pushBits(bits, 0, (8 - (bits.length % 8)) % 8); + for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) pushBits(bits, pad, 8); + const data: number[] = []; + for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0)); + + const codewords = interleave(data, version, ecc); + const qr = new Matrix(version); + qr.drawFunctionPatterns(ecc); + qr.drawCodewords(codewords); + + let mask = o.mask ?? -1; + if (mask < 0) { + let best = Infinity; + for (let m = 0; m < 8; m++) { + qr.applyMask(m); + qr.drawFormat(ecc, m); + const p = qr.penalty(); + if (p < best) [best, mask] = [p, m]; + qr.applyMask(m); // XOR again: undone + } + } + qr.applyMask(mask); + qr.drawFormat(ecc, mask); + return { version, ecc, mask, mode, size: qr.size, modules: qr.modules }; +} + +/** Splits into blocks, adds each block's error correction, interleaves. */ +function interleave(data: number[], version: number, ecc: Ecc): number[] { + const numBlocks = NUM_BLOCKS[ecc][version]; + const blockEcc = ECC_CODEWORDS_PER_BLOCK[ecc][version]; + const raw = Math.floor(rawDataModules(version) / 8); + const numShort = numBlocks - (raw % numBlocks); + const shortLen = Math.floor(raw / numBlocks); + const divisor = rsDivisor(blockEcc); + const blocks: number[][] = []; + for (let i = 0, k = 0; i < numBlocks; i++) { + const dat = data.slice(k, k + shortLen - blockEcc + (i < numShort ? 0 : 1)); + k += dat.length; + const block = [...dat, ...rsRemainder(dat, divisor)]; + // Short blocks get a gap so that every block has the same length while interleaving. + if (i < numShort) block.splice(shortLen - blockEcc, 0, -1); + blocks.push(block); + } + const result: number[] = []; + for (let i = 0; i < blocks[0].length; i++) { + for (const b of blocks) if (b[i] !== -1) result.push(b[i]); + } + return result; +} + +class Matrix { + readonly size: number; + readonly modules: boolean[][]; + private readonly isFunction: boolean[][]; + + constructor(readonly version: number) { + this.size = version * 4 + 17; + this.modules = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false)); + this.isFunction = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false)); + } + + private set(x: number, y: number, dark: boolean) { + this.modules[y][x] = dark; + this.isFunction[y][x] = true; + } + + alignmentPositions(): number[] { + const v = this.version; + if (v === 1) return []; + const count = Math.floor(v / 7) + 2; + const step = v === 32 ? 26 : Math.ceil((v * 4 + 4) / (count * 2 - 2)) * 2; + const result = [6]; + for (let pos = this.size - 7; result.length < count; pos -= step) result.splice(1, 0, pos); + return result; + } + + drawFunctionPatterns(ecc: Ecc) { + for (let i = 0; i < this.size; i++) { + this.set(6, i, i % 2 === 0); + this.set(i, 6, i % 2 === 0); + } + this.finder(3, 3); + this.finder(this.size - 4, 3); + this.finder(3, this.size - 4); + const align = this.alignmentPositions(); + const n = align.length; + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (!((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0))) { + this.alignment(align[i], align[j]); + } + } + } + this.drawFormat(ecc, 0); // reserves the area; drawn again once the mask is known + this.drawVersion(); + } + + private finder(x: number, y: number) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const d = Math.max(Math.abs(dx), Math.abs(dy)); + const xx = x + dx, yy = y + dy; + if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size) this.set(xx, yy, d !== 2 && d !== 4); + } + } + } + + private alignment(x: number, y: number) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) this.set(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } + + drawFormat(ecc: Ecc, mask: number) { + const data = (ECC_BITS[ecc] << 3) | mask; + let rem = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; + const bit = (i: number) => ((bits >>> i) & 1) !== 0; + for (let i = 0; i <= 5; i++) this.set(8, i, bit(i)); + this.set(8, 7, bit(6)); + this.set(8, 8, bit(7)); + this.set(7, 8, bit(8)); + for (let i = 9; i < 15; i++) this.set(14 - i, 8, bit(i)); + for (let i = 0; i < 8; i++) this.set(this.size - 1 - i, 8, bit(i)); + for (let i = 8; i < 15; i++) this.set(8, this.size - 15 + i, bit(i)); + this.set(8, this.size - 8, true); // the dark module + } + + private drawVersion() { + if (this.version < 7) return; + let rem = this.version; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits = (this.version << 12) | rem; + for (let i = 0; i < 18; i++) { + const dark = ((bits >>> i) & 1) !== 0; + const a = this.size - 11 + (i % 3); + const b = Math.floor(i / 3); + this.set(a, b, dark); + this.set(b, a, dark); + } + } + + /** The zigzag from the bottom right, two columns at a time, skipping the vertical timing line. */ + drawCodewords(codewords: number[]) { + let i = 0; + const total = codewords.length * 8; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = ((right + 1) & 2) === 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.isFunction[y][x] && i < total) { + this.modules[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0; + i++; + } + } + } + } + } + + applyMask(mask: number) { + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + if (this.isFunction[y][x]) continue; + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 === 0; + break; + case 1: + invert = y % 2 === 0; + break; + case 2: + invert = x % 3 === 0; + break; + case 3: + invert = (x + y) % 3 === 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) === 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; + break; + default: + throw new RangeError("Mask must be 0 to 7"); + } + if (invert) this.modules[y][x] = !this.modules[y][x]; + } + } + } + + /** The four penalty rules of the standard; lower is better. */ + penalty(): number { + const n = this.size; + const m = this.modules; + let result = 0; + // Rule 1 (runs of 5+ same color) and rule 3 (finder-like patterns), on rows then columns. + for (const vertical of [false, true]) { + for (let a = 0; a < n; a++) { + let runColor = false; + let run = 0; + const history = [0, 0, 0, 0, 0, 0, 0]; + for (let b = 0; b < n; b++) { + const dark = vertical ? m[b][a] : m[a][b]; + if (dark === runColor) { + run++; + if (run === 5) result += 3; + else if (run > 5) result++; + } else { + this.addHistory(run, history, n); + if (!runColor) result += this.finderLike(history) * 40; + runColor = dark; + run = 1; + } + } + result += this.finderTerminate(runColor, run, history, n) * 40; + } + } + // Rule 2: 2×2 blocks. + for (let y = 0; y < n - 1; y++) { + for (let x = 0; x < n - 1; x++) { + const c = m[y][x]; + if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) result += 3; + } + } + // Rule 4: balance of dark modules. + const dark = m.reduce((s, row) => s + row.filter(Boolean).length, 0); + const total = n * n; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + result += k * 10; + return result; + } + + private addHistory(run: number, history: number[], n: number) { + if (history[0] === 0) run += n; // the light border before the row + history.pop(); + history.unshift(run); + } + + private finderLike(h: number[]): number { + const u = h[1]; + const core = u > 0 && h[2] === u && h[3] === u * 3 && h[4] === u && h[5] === u; + return (core && h[0] >= u * 4 && h[6] >= u ? 1 : 0) + (core && h[6] >= u * 4 && h[0] >= u ? 1 : 0); + } + + private finderTerminate(color: boolean, run: number, history: number[], n: number): number { + if (color) { + this.addHistory(run, history, n); + run = 0; + } + run += n; + this.addHistory(run, history, n); + return this.finderLike(history); + } +} + +/** The largest text of each mode that fits at a level (version 40). */ +export function capacity(mode: Mode, ecc: Ecc): number { + const bits = dataCodewords(40, ecc) * 8 - 4 - countBits(mode, 40); + if (mode === "numeric") return Math.floor(bits / 10) * 3 + (bits % 10 >= 7 ? 2 : bits % 10 >= 4 ? 1 : 0); + if (mode === "alphanumeric") return Math.floor(bits / 11) * 2 + (bits % 11 >= 6 ? 1 : 0); + return Math.floor(bits / 8); +} diff --git a/extensions/qr-codes/qr_test.ts b/extensions/qr-codes/qr_test.ts new file mode 100644 index 0000000..5ae1690 --- /dev/null +++ b/extensions/qr-codes/qr_test.ts @@ -0,0 +1,150 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { capacity, dataCodewords, encode, modeFor, TooLong } from "./qr.ts"; +import { crc32, png, svg } from "./png.ts"; + +// Reference symbols made by python-qrcode 8.0 (single segment, same mask and +// version), every one of them also decoded by zxing-cpp from our PNG output. +// The modules are row by row, dark = 1, after a leading 1 digit, in hex. +const FIXTURES = [ + { + text: "HELLO WORLD", + ecc: "M", + mask: 0, + version: 1, + hex: "1fe2bfc17106e8abb7455dbabaec13907faafe00000aa4893c48447f4b1eb3ae4f53a805117f825904c68bacbfdd1a8aebdd" + + "30438bfed708", + }, + { + text: "01234567890123456789", + ecc: "H", + mask: 1, + version: 2, + hex: "1fe0cbfc145106ea68bb75cf5dbae9aec171d07faaafe00c50027875f78c74251ca673323656a7369cb2654e36df3d0400fb" + + "cebc4fd804cc43fa66af05751aba13fedd1a8b2ea369704ecc6fe40558", + }, + { + text: "https://example.com/?q=backslash&lang=fr", + ecc: "Q", + mask: 6, + version: 4, + hex: "1fe64863fc1646b506e989b2bb75a72d5dba8b6a2ec11d34d07faaaaafe012a32005eca796d18f78f8723e439cbedb71b2e6" + + "da6568a1f68688a930d7110b12900e19d034b99fc6cd689154c9c4c55a1a40a1de5fa9a8d542a6068c27a6a8fc7088e46a4d" + + "674f8f900592045bf9ffeea905a0671fbab9bdf95d4415eb6e9812c6705c6847ffe0ee1f80", + }, + { + text: "Bonjour, \u00e7a va ? \u00c9lodie \u20ac \u65e5\u672c", + ecc: "L", + mask: 2, + version: 3, + hex: "1fe2d3bfc1538506e836ebb75d4a5dba407aec16c1107faaaafe00e7100fbd0d5524979f5d2e98369c11909b226b702c6205" + + "1fdf9e2e594ae7ef9c631406e8f7172e3c1bd513259c1b87ebaff0070944bfae1ea904a5d12bab16fa5d577e7eea987c5051" + + "a792fe8b64a0", + }, + { + text: "a".repeat(300), + ecc: "M", + mask: 1, + version: 13, + hex: "1fee80ccc92aeaafbfc10a2cc89912112106ea4026ee3ddddd8bb7406b91a0aaaaa95dba2b0cadfaaaaaaaec15aecf845111" + + "11107faaaaaaaaaaaaaafe001eec4b111121000a31cb313faaabab12828af989068aaaaa863a2af93cb6f77736bba1efc0aa" + + "11111c1af79ca372daaaaad2d865af1a906aaaaba862e2ac83cb77777b6bb91ed8caa1111201af69cb852daaaabb2d869af1" + + "7904aaaaaa86282ae23caf777736b391ec8caa0111d01a769cbf52da2aacb2de69af1b9066aabaab5282b703cb1777f37b39" + + "1e8ccaa1111101af69cbe52daaaaab2da69ac0f906aaaaaa86282af63cb7777736a391e0ecaa101d101a5684be52daa2cab2" + + "de69ac0f906a6baaa86283bf624b717f736e792ecec6a1111101a7f9cfe58faaaaabfdec51c0fc46aaaaa4466a73f65eb777" + + "776aa110ecef9111c1111a6f867e53faac2aafde49280f9d2ab6aa286cb69f63dd779776aa6bd4cec8a1111134a6303fe53a" + + "aaaaab75e4b280f9d2aaaaa286caebf6bdb777776aa6b17cec881d10134a6f22ee13aacaa2b75e2b248f952baa6a286eaeff" + + "73bd7f7176aa6355fe4ba1111134a6e82fe52eaaaaab75e6624cf9e2aaaa8286caefefbdd77776eaa6855fa88ad111034a63" + + "02fcb3acaaaa375e0e248d9d3aaaa6284faef9bbddf77716aa6b55f288a2111134ae282fcb3abaaaab75c0e24b99d2aaa8a2" + + "86bcee81bdd7776f6ab0255be88a1111134a9a82bb33faaaaabfd8062689946aaaaac47faef83bab777776ab04557c8b1121" + + "1111aba029b33fabaaabfddd224899aaaa8aa442eaff03bf7776f72af04693c891111112a8fe8adb2eaaaaaabdc8", + }, + { + text: "mask test 5", + ecc: "M", + mask: 5, + version: 1, + hex: "1fe23fc16906eb4bb75e5dba0aec11907faafe0100082b67542d9108b9c4b3ae4b988004657f9c150445eba320dd24c2e8fa" + + "704704feff10", + }, +]; + +const bitsOf = (hex: string) => [...hex].map((h) => parseInt(h, 16).toString(2).padStart(4, "0")).join("").slice(4); + +Deno.test("matches the reference symbols bit for bit", () => { + for (const f of FIXTURES) { + const qr = encode(f.text, { ecc: f.ecc as "L", mask: f.mask, boost: false }); + assertEquals([qr.version, qr.mask, qr.ecc], [f.version, f.mask, f.ecc], f.text); + const expected = bitsOf(f.hex).slice(0, qr.size * qr.size); + assertEquals(qr.modules.map((r) => r.map((b) => (b ? "1" : "0")).join("")).join(""), expected, f.text); + } +}); + +Deno.test("modes, versions and capacity", () => { + assertEquals(modeFor("0123"), "numeric"); + assertEquals(modeFor("HELLO WORLD $%*+-./:"), "alphanumeric"); + assertEquals(modeFor("Hello"), "byte"); + assertEquals(dataCodewords(1, "M"), 16); + assertEquals(dataCodewords(40, "L"), 2956); + assertEquals(capacity("byte", "L"), 2953); + assertEquals(capacity("numeric", "L"), 7089); + assertEquals(capacity("alphanumeric", "H"), 1852); + assertEquals(encode("x".repeat(2953), { ecc: "L" }).version, 40); + assertThrows(() => encode("x".repeat(2954), { ecc: "L" }), TooLong); + // Boosting raises the level while it fits in the same version. + assertEquals(encode("HELLO WORLD", { ecc: "L" }).ecc, "Q"); + assertEquals(encode("HELLO WORLD", { ecc: "L", boost: false }).ecc, "L"); +}); + +Deno.test("structure: finders, timing, dark module, version info", () => { + const qr = encode("https://backslash.app", { ecc: "M" }); + const m = qr.modules; + const n = qr.size; + for (const [x, y] of [[0, 0], [n - 7, 0], [0, n - 7]]) { + for (let i = 0; i < 7; i++) assert(m[y][x + i] && m[y + 6][x + i] && m[y + i][x] && m[y + i][x + 6]); + assert(!m[y + 1][x + 1] && m[y + 3][x + 3]); + } + for (let i = 8; i < n - 8; i++) assertEquals(m[6][i], i % 2 === 0); + assert(m[n - 8][8], "dark module"); + // Version 7 and up carry their version twice; 000111 110010 010100 for 7. + const v7 = encode("x".repeat(100), { ecc: "M", minVersion: 7 }); + assertEquals(v7.version, 7); + let bits = ""; + for (let i = 17; i >= 0; i--) bits += v7.modules[Math.floor(i / 3)][v7.size - 11 + (i % 3)] ? "1" : "0"; + assertEquals(bits, "000111110010010100"); +}); + +Deno.test("automatic mask is deterministic and valid", () => { + const a = encode("Bonjour, ça va ?"); + const b = encode("Bonjour, ça va ?"); + assertEquals(a.mask, b.mask); + assert(a.mask >= 0 && a.mask <= 7); +}); + +Deno.test("PNG output", async () => { + assertEquals((crc32(new TextEncoder().encode("IEND")) ^ 0xffffffff) >>> 0, 0xae426082); + const qr = encode("HELLO WORLD", { ecc: "M", mask: 0, boost: false }); + const data = await png(qr.modules, { scale: 3, border: 4 }); + assertEquals([...data.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const view = new DataView(data.buffer, data.byteOffset); + assertEquals(new TextDecoder().decode(data.subarray(12, 16)), "IHDR"); + const side = (21 + 8) * 3; + assertEquals([view.getUint32(16), view.getUint32(20)], [side, side]); + // Inflate IDAT and check a few pixels: the quiet zone is white, the finder's corner black. + const idatLen = view.getUint32(33); + assertEquals(new TextDecoder().decode(data.subarray(37, 41)), "IDAT"); + const stream = new Blob([data.slice(41, 41 + idatLen) as BlobPart]).stream().pipeThrough( + new DecompressionStream("deflate"), + ); + const raw = new Uint8Array(await new Response(stream).arrayBuffer()); + assertEquals(raw.length, (side + 1) * side); + const px = (x: number, y: number) => raw[y * (side + 1) + 1 + x]; + assertEquals(px(0, 0), 255); + assertEquals(px(12, 12), 0); + assertEquals(px(12 + 3, 12 + 3), 255); + assertEquals(new TextDecoder().decode(data.subarray(data.length - 8, data.length - 4)), "IEND"); +}); + +Deno.test("SVG output", () => { + const s = svg(encode("A", { mask: 0 }).modules); + assert(s.startsWith("<?xml") && s.includes('viewBox="0 0 29 29"') && s.includes("M4 4h7v1h-7z")); +}); diff --git a/extensions/qr-codes/system.ts b/extensions/qr-codes/system.ts new file mode 100644 index 0000000..e126cc6 --- /dev/null +++ b/extensions/qr-codes/system.ts @@ -0,0 +1,249 @@ +// Files, programs and the background scan. + +import { exec, storage } from "@backslash/api"; +import { + areaTools, + detectSession, + type Entry, + type Env, + type Family, + fileKey, + mergeHistory, + osFamily, + picturesDir, + saveName, + scaleFor, + scanEntry, + scanScript, + type Session, + SESSION_ENV, + type ShotTool, + systemdRunArgs, +} from "./lib.ts"; +import { type Ecc, encode, type QrCode } from "./qr.ts"; +import { png, svg } from "./png.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/qr`; +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<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +/** Whether a program can be started (installed and allowed). */ +export async function installed(program: string, args = ["--version"]): Promise<boolean> { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +const PROBE: Record<string, string[]> = { import: ["-version"], slurp: ["-h"], grim: ["-h"] }; + +// --------------------------------------------------------------------------- +// Generating + +export interface Generated { + code: QrCode; + /** The PNG in the cache, shown in the detail panel. */ + file: string; +} + +/** Encodes the text and writes its PNG to the cache (reused when it is already there). */ +export async function generate(text: string, ecc: Ecc): Promise<Generated> { + const code = encode(text, { ecc }); + const file = `${CACHE}/${fileKey(text, ecc)}.png`; + const exists = await Deno.stat(file).then((s) => s.size > 0).catch(() => false); + if (!exists) { + await Deno.mkdir(CACHE, { recursive: true }); + await Deno.writeFile(file, await png(code.modules, { scale: scaleFor(code.size) })); + } + return { code, file }; +} + +export async function picturesFolder(): Promise<string> { + const dirs = await Deno.readTextFile(`${HOME}/.config/user-dirs.dirs`).catch(() => ""); + return `${picturesDir(dirs, HOME)}/QR Codes`; +} + +/** Saves a PNG (large, for printing) or an SVG in ~/Pictures/QR Codes. */ +export async function save(text: string, g: Generated, format: "png" | "svg"): Promise<string> { + const folder = await picturesFolder(); + await Deno.mkdir(folder, { recursive: true }); + const path = `${folder}/${saveName(text, new Date(), format)}`; + if (format === "svg") await Deno.writeTextFile(path, svg(g.code.modules)); + else await Deno.writeFile(path, await png(g.code.modules, { scale: Math.max(10, scaleFor(g.code.size, 1200)) })); + return path; +} + +async function pipe(program: string, args: string[], input?: Uint8Array): Promise<boolean> { + try { + const child = new Deno.Command(program, { + args, + stdin: input ? "piped" : "null", + stdout: "null", + stderr: "null", + }).spawn(); + if (input) { + const w = child.stdin.getWriter(); + await w.write(input); + await w.close(); + } + // wl-copy forks to serve the clipboard; its first process exits right away. + return (await child.status).success; + } catch { + return false; + } +} + +/** Copies a PNG to the clipboard (wl-copy, then xclip). */ +export async function copyImage(file: string): Promise<boolean> { + const wl = async () => pipe("wl-copy", ["--type", "image/png"], await Deno.readFile(file)); + const x = () => pipe("xclip", ["-selection", "clipboard", "-t", "image/png", "-i", file]); + return session().wayland ? (await wl()) || (await x()) : (await x()) || (await wl()); +} + +// --------------------------------------------------------------------------- +// History + +export const history = async (): Promise<Entry[]> => ((await storage.get("history")) ?? []) as unknown as Entry[]; + +export async function remember(entries: Entry[]): Promise<void> { + await storage.set("history", mergeHistory(await history(), entries) as any); +} + +export async function forget(entry?: Entry): Promise<void> { + if (!entry) { + await storage.delete("history"); + await Deno.remove(`${CACHE}/scans`, { recursive: true }).catch(() => {}); + return; + } + await storage.set( + "history", + (await history()).filter((e) => !(e.kind === entry.kind && e.text === entry.text)) as any, + ); +} + +/** Moves the scans written by the background script into the history. Returns them, newest first. */ +export async function importScans(): Promise<Entry[]> { + const added: Entry[] = []; + try { + for await (const f of Deno.readDir(`${CACHE}/scans`)) { + if (!f.isFile || !f.name.endsWith(".txt")) continue; + const path = `${CACHE}/scans/${f.name}`; + const e = scanEntry(f.name, await Deno.readTextFile(path).catch(() => "")); + const picture = path.replace(/\.txt$/, ".png"); + if (e && (await Deno.stat(picture).catch(() => undefined))) e.image = picture; + if (e) added.push(e); + await Deno.remove(path).catch(() => {}); + } + } catch { + // No scan yet. + } + if (added.length) await remember(added); + return added.sort((a, b) => b.at - a.at); +} + +// --------------------------------------------------------------------------- +// Scanning + +export class MissingTool extends Error { + constructor(message: string, public packages: string[]) { + super(message); + } +} + +export async function captureTool(s: Session): Promise<ShotTool | undefined> { + 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; +} + +export const hasZbar = () => installed("zbarimg", ["--version"]); + +/** Starts the background job: select an area, decode, notify. */ +export async function scanScreen(o: { openLinks?: boolean; copy?: boolean }): Promise<void> { + const s = session(); + const [tool, zbar] = await Promise.all([captureTool(s), hasZbar()]); + 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", []); + const script = scanScript({ tool, cache: CACHE, wayland: s.wayland, openLinks: o.openLinks, copy: o.copy }); + await Deno.mkdir(CACHE, { recursive: true }); + try { + await exec("systemd-run", systemdRunArgs(`backslash-qr-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 codes in a picture; empty when there is none. */ +export async function decodeFile(path: string): Promise<string[]> { + try { + const out = await exec("zbarimg", ["--raw", "-q", "--", path]); + return out ? [out] : []; + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new MissingTool("zbarimg is needed to read QR codes", ["zbarimg"]); + // zbarimg exits with 4 when it finds nothing. + if (/exited with 4/.test(String(e))) return []; + throw e; + } +} + +/** Saves the picture in the clipboard (if any) to the cache. */ +export async function clipboardImage(): Promise<string | undefined> { + const file = `${CACHE}/clipboard.png`; + await Deno.mkdir(CACHE, { recursive: true }); + const tries: [string, string[]][] = session().wayland + ? [["wl-paste", ["--no-newline", "--type", "image/png"]], ["xclip", [ + "-selection", + "clipboard", + "-t", + "image/png", + "-o", + ]]] + : [["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]], ["wl-paste", [ + "--no-newline", + "--type", + "image/png", + ]]]; + for (const [program, args] of tries) { + try { + const out = await new Deno.Command(program, { args, stdin: "null", stdout: "piped", stderr: "null" }).output(); + // A PNG starts with \x89PNG. + if (out.success && out.stdout.length > 8 && out.stdout[0] === 0x89 && out.stdout[1] === 0x50) { + await Deno.writeFile(file, out.stdout); + return file; + } + } catch { + // Not installed: try the next one. + } + } + return undefined; +} diff --git a/extensions/qr-codes/ui.ts b/extensions/qr-codes/ui.ts new file mode 100644 index 0000000..41e1a98 --- /dev/null +++ b/extensions/qr-codes/ui.ts @@ -0,0 +1,276 @@ +// Items, actions and views shared by the commands. + +import { + type Action, + action, + close, + type Color, + type Context, + copy, + detail, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + openExternal, + paste, + refresh, + toast, +} from "@backslash/api"; +import { capturePackages, type Entry, installMarkdown, parsePayload, type PayloadKind, wifiPayload } from "./lib.ts"; +import { capacity, type Ecc, TooLong } from "./qr.ts"; +import { + copyImage, + family, + forget, + generate, + type Generated, + MissingTool, + picturesFolder, + remember, + save, + scanScreen, + session, + tilde, +} from "./system.ts"; + +export interface Prefs { + ecc?: string; + openLinks?: boolean; +} + +export const eccOf = (ctx: Context): Ecc => { + const e = String((ctx.preferences as Prefs).ecc ?? "M").trim().toUpperCase(); + return (["L", "M", "Q", "H"].includes(e) ? e : "M") as Ecc; +}; + +const ECC_NAMES: Record<Ecc, string> = { + L: "Low (7 %)", + M: "Medium (15 %)", + Q: "Quartile (25 %)", + H: "High (30 %)", +}; + +export const KINDS: Record<PayloadKind, { icon: string; tint: Color; name: string }> = { + url: { icon: Icons.link, tint: "blue", name: "Link" }, + wifi: { icon: Icons.wifi, tint: "teal", name: "Wi-Fi network" }, + email: { icon: Icons.mail, tint: "orange", name: "Email" }, + phone: { icon: Icons.bell, tint: "green", name: "Phone number" }, + sms: { icon: Icons.comment, tint: "green", name: "Text message" }, + geo: { icon: Icons.pin, tint: "red", name: "Location" }, + contact: { icon: Icons.person, tint: "purple", name: "Contact" }, + otp: { icon: Icons.key, tint: "yellow", name: "One-time password" }, + event: { icon: Icons.calendar, tint: "pink", name: "Event" }, + text: { icon: Icons.text, tint: "gray", name: "Text" }, +}; + +/** Copies the text (concealed when it holds a secret), opens what can be opened. */ +function contentActions(text: string, secret: boolean, openTarget?: string): Action[] { + const fields = parsePayload(text).fields.filter((f) => f.secret); + return [ + ...(openTarget ? [open(openTarget, "Open", { icon: Icons.external })] : []), + copy(text, "Copy Text", { concealed: secret, section: "Copy" }), + paste(text, "Paste Text", { concealed: secret, section: "Copy" }), + ...fields.map((f) => copy(f.value, `Copy ${f.label}`, { concealed: true, section: "Copy", icon: Icons.key })), + ]; +} + +/** Copy the picture, save PNG/SVG. */ +function imageActions(text: string, g: Generated): Action[] { + return [ + action("Copy QR Code Image", async () => { + if (!(await copyImage(g.file))) { + await toast("Couldn't copy the picture", { style: "failure", message: "Install wl-clipboard or xclip" }); + return; + } + await remember([{ kind: "generated", text, at: Date.now() }]); + return close("QR code copied"); + }, { icon: Icons.image, shortcut: "<Ctrl><Shift>c" }), + action("Save to Pictures", async () => { + const path = await save(text, g, "png"); + await remember([{ kind: "generated", text, at: Date.now() }]); + await toast(`Saved to ${tilde(path)}`, { style: "success" }); + return close(); + }, { icon: Icons.download, shortcut: "<Ctrl>s", section: "Save" }), + action("Save as SVG", async () => { + const path = await save(text, g, "svg"); + await toast(`Saved to ${tilde(path)}`, { style: "success" }); + return close(); + }, { icon: Icons.download, section: "Save" }), + copy(g.file, "Copy Image Path", { section: "Save", icon: Icons.file }), + action("Open QR Codes Folder", async () => { + const folder = await picturesFolder(); + await Deno.mkdir(folder, { recursive: true }).catch(() => {}); + await openExternal(folder); + return close(); + }, { icon: Icons.folder, section: "Save" }), + ]; +} + +/** A text as a QR code: the picture in the detail panel. */ +export async function qrItem( + text: string, + ecc: Ecc, + o: { id?: string; section?: string; subtitle?: string; extra?: Metadata[]; actions?: Action[] } = {}, +): Promise<Item> { + const p = parsePayload(text); + const kind = KINDS[p.kind]; + let g: Generated; + try { + g = await generate(text, ecc); + } catch (e) { + if (!(e instanceof TooLong)) throw e; + return { + id: o.id ?? "too-long", + title: "Too long for a QR code", + subtitle: `${new TextEncoder().encode(text).length} bytes; at most ${capacity("byte", ecc)} at level ${ecc}`, + icon: { source: Icons.alert, tint: "orange" }, + section: o.section, + detail: { markdown: "Shorten the text, or pick the **Low** error correction level in the preferences." }, + }; + } + const shown = p.secret ? p.fields.map((f) => (f.secret ? { ...f, value: "••••••••" } : f)) : p.fields; + return { + id: o.id ?? `qr:${text}`, + title: p.secret ? p.title : text.length > 120 ? `${text.slice(0, 120)}…` : text.split("\n")[0], + subtitle: o.subtitle ?? kind.name, + icon: { source: Icons.qr, tint: kind.tint }, + section: o.section, + detail: { + image: g.file, + metadata: [ + meta.tags("Content", [{ text: kind.name, color: kind.tint }]), + ...shown.map((f) => meta.text(f.label, f.value)), + meta.separator(), + meta.text("Version", `${g.code.version} · ${g.code.size} × ${g.code.size} modules`, { icon: Icons.qr }), + meta.text("Error correction", ECC_NAMES[g.code.ecc], { icon: Icons.shield }), + meta.text("Encoding", g.code.mode === "byte" ? "Bytes (UTF-8)" : g.code.mode, { icon: Icons.text }), + ...(o.extra ?? []), + ], + }, + actions: [...imageActions(text, g), ...contentActions(text, !!p.secret, p.open), ...(o.actions ?? [])], + }; +} + +/** A history line: generated ones show their code, scanned ones the picture they came from. */ +export async function entryItem(e: Entry, ecc: Ecc): Promise<Item> { + const when = new Date(e.at).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }); + const remove = action("Remove from History", async () => { + await forget(e); + return refresh(); + }, { icon: Icons.trash, destructive: true, section: "Danger" }); + const clear = action("Clear History", async () => { + await forget(); + return refresh(); + }, { icon: Icons.trash, destructive: true, section: "Danger", confirm: "Forget every generated and scanned code?" }); + if (e.kind === "generated") { + return await qrItem(e.text, ecc, { + id: `history:${e.kind}:${e.text}`, + section: "Recent", + subtitle: `Generated · ${when}`, + extra: [meta.text("Created", when, { icon: Icons.history })], + actions: [remove, clear], + }); + } + const p = parsePayload(e.text); + const kind = KINDS[p.kind]; + const shown = p.fields.map((f) => (f.secret ? { ...f, value: "••••••••" } : f)); + return { + id: `history:${e.kind}:${e.text}`, + title: p.title, + subtitle: `Scanned · ${when}`, + icon: { source: kind.icon, tint: kind.tint }, + section: "Recent", + accessories: [{ tag: kind.name }], + detail: { + image: e.image, + markdown: p.secret || p.kind === "url" ? undefined : "```\n" + e.text.slice(0, 3000) + "\n```", + metadata: [ + meta.tags("Content", [{ text: kind.name, color: kind.tint }]), + ...shown.map((f) => meta.text(f.label, f.value)), + meta.separator(), + meta.text("Scanned", when, { icon: Icons.scan }), + ], + }, + actions: [ + ...contentActions(e.text, !!p.secret, p.open), + action("Show as QR Code", async () => { + const item = await qrItem(e.text, ecc); + return list({ title: "QR Code", items: [item] }); + }, { icon: Icons.qr, section: "Copy" }), + remove, + clear, + ], + }; +} + +// --------------------------------------------------------------------------- +// Scanning + +/** Hides the launcher and starts the background scan; failures become an explanation. */ +export const startScan = (ctx: Context) => async () => { + try { + await scanScreen({ openLinks: !!(ctx.preferences as Prefs).openLinks }); + } catch (e) { + if (e instanceof MissingTool) { + const fam = await family(); + const packages = e.packages.length ? e.packages : capturePackages(session()); + return detail({ + title: "Scan QR Code", + markdown: installMarkdown(`**${e.message}.** Install it, then try again.`, packages, fam), + }); + } + throw e; + } + return close(); +}; + +export function scanItem(ctx: Context): Item { + return { + id: "scan", + title: "Scan QR Code on Screen", + subtitle: "Select the code: the text is copied", + icon: { source: Icons.scan, tint: "blue" }, + section: "Scan", + keywords: ["scan", "read", "decode", "lire", "scanner", "screen", "écran"], + detail: { + markdown: + "Drag a rectangle around a QR code anywhere on the screen. Its content is copied, shown in a notification and kept in the history here. Wi-Fi passwords and one-time password seeds are not copied: open this command to see them.", + }, + actions: [action("Scan QR Code on Screen", startScan(ctx), { icon: Icons.scan })], + }; +} + +// --------------------------------------------------------------------------- +// Wi-Fi + +export function wifiForm(ecc: Ecc) { + return form({ + title: "Wi-Fi QR Code", + submitTitle: "Create QR Code", + fields: [ + field.text("ssid", "Network name (SSID)", { required: true }), + field.password("password", "Password", { placeholder: "Empty for an open network" }), + field.dropdown("security", "Security", [ + { value: "WPA", title: "WPA / WPA2 / WPA3" }, + { value: "WEP", title: "WEP" }, + { value: "nopass", title: "None" }, + ], { value: "WPA" }), + field.checkbox("hidden", "Hidden network"), + ], + async submit(values) { + const text = wifiPayload({ + ssid: String(values.ssid), + password: values.security === "nopass" ? undefined : String(values.password ?? "") || undefined, + security: String(values.security), + hidden: !!values.hidden, + }); + const item = await qrItem(text, ecc, { subtitle: "Wi-Fi network · scan it to join" }); + return list({ title: `Wi-Fi · ${values.ssid}`, items: [item] }); + }, + }); +} diff --git a/extensions/radio/api.ts b/extensions/radio/api.ts new file mode 100644 index 0000000..3d74bcd --- /dev/null +++ b/extensions/radio/api.ts @@ -0,0 +1,93 @@ +// The radio-browser.info API: a community directory with several mirrors. + +import { storage } from "@backslash/api"; +import type { Station } from "./lib.ts"; + +/** Mirrors in `[permissions] net`, tried in order; the one that answered is used first next time. */ +export const MIRRORS = [ + "de1.api.radio-browser.info", + "fi1.api.radio-browser.info", + "de2.api.radio-browser.info", + "all.api.radio-browser.info", +]; +const HEADERS = { + "User-Agent": "Backslash-Radio/1.0 (+https://github.com/backslash-app/plugins)", + Accept: "application/json", +}; + +type Json = Parameters<typeof storage.set>[1]; + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +let preferred: string | undefined; + +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { + preferred ??= await load<string>("mirror"); + const order = [...new Set([preferred, ...MIRRORS].filter((m): m is string => !!m && MIRRORS.includes(m)))]; + let last = ""; + for (const host of order) { + const timeout = AbortSignal.timeout(8000); + try { + const res = await fetch(`https://${host}${path}`, { + headers: HEADERS, + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + if (!res.ok) { + await res.body?.cancel(); + last = `${host} answered ${res.status}`; + if (res.status >= 500 || res.status === 429) continue; + throw new Error(`radio-browser.info answered ${res.status}`); + } + const data = await res.json() as T; + if (host !== preferred) { + preferred = host; + await save("mirror", host); + } + return data; + } catch (e) { + if (signal?.aborted) throw e; + if ((e as Error).message.startsWith("radio-browser.info answered")) throw e; + last = timeout.aborted ? `${host} timed out` : `${host}: ${(e as Error).message}`; + } + } + throw new Error(`radio-browser.info is unreachable (${last})`); +} + +const base = { hidebroken: "true", order: "votes", reverse: "true" }; + +export function searchStations(params: Record<string, string>, signal?: AbortSignal, limit = 40): Promise<Station[]> { + const q = new URLSearchParams({ ...base, limit: String(limit), ...params }); + return get<Station[]>(`/json/stations/search?${q}`, signal); +} + +export function topStations(signal?: AbortSignal, limit = 50): Promise<Station[]> { + return get<Station[]>(`/json/stations/topvote/${limit}?hidebroken=true`, signal); +} + +/** Counts a play, as radio-browser asks clients to, and returns the stream URL it resolved. */ +export async function click(s: Station): Promise<string> { + try { + const r = await get<{ ok?: boolean; url?: string }>(`/json/url/${encodeURIComponent(s.stationuuid)}`); + return r.ok && r.url ? r.url : s.url_resolved || s.url; + } catch { + return s.url_resolved || s.url; + } +} + +// --------------------------------------------------------------------------- +// Favorites + +export const favorites = async () => await load<Station[]>("favorites") ?? []; + +export async function toggleFavorite(s: Station): Promise<boolean> { + const list = await favorites(); + const there = list.some((f) => f.stationuuid === s.stationuuid); + await save("favorites", there ? list.filter((f) => f.stationuuid !== s.stationuuid) : [...list, s]); + return !there; +} diff --git a/extensions/radio/backslash.toml b/extensions/radio/backslash.toml new file mode 100644 index 0000000..80b23e6 --- /dev/null +++ b/extensions/radio/backslash.toml @@ -0,0 +1,60 @@ +[extension] +id = "radio" +title = "Internet Radio" +description = "Search 50,000 internet radio stations by name, genre or country and play them in the background with mpv, ffplay or VLC" +version = "1.0.0" +author = "Backslash" +icon = "bs-radio" +license = "MIT" + +# The radio-browser.info community API: its mirrors, tried in this order +# (all.api is the round-robin name). Station logos live on each station's own +# website, so they are not shown: that would need access to any host. +# Stations play in a transient systemd user unit (systemd-run) so they keep +# playing once the launcher is hidden; mpv, ffplay and cvlc are run once to +# find which is installed. A small mpv script in ~/.cache/backslash/radio writes +# the song title (ICY metadata) there for the launcher to read; HOME locates it. +[permissions] +net = ["de1.api.radio-browser.info", "de2.api.radio-browser.info", "fi1.api.radio-browser.info", "all.api.radio-browser.info"] +run = ["systemd-run", "systemctl", "mpv", "ffplay", "cvlc"] +read = ["~/.cache/backslash/radio", "/etc/os-release"] +write = ["~/.cache/backslash/radio"] +env = ["HOME"] + +[[commands]] +name = "stations" +title = "Internet Radio" +description = "Top stations, your favorites and what's playing; type to search by name or genre" +icon = "bs-radio" +keywords = [ + "radio", + "stations", + "internet radio", + "stream", + "music", + "fm", + "listen", + "webradio", + "station", + "musique", + "écouter", +] + +[[commands]] +name = "stop" +title = "Stop Radio" +description = "Stops the station playing in the background" +icon = "bs-stop" +mode = "no-view" +keywords = ["radio", "stop", "silence", "arrêter", "couper"] + +[[preferences]] +name = "player" +title = "Player" +description = "auto (mpv, then ffplay, then VLC), mpv, ffplay or vlc" +default = "auto" + +[[preferences]] +name = "country" +title = "Your country" +description = "Two-letter code for the Country view when nothing is typed: FR, US, DE…" diff --git a/extensions/radio/commands/stations.ts b/extensions/radio/commands/stations.ts new file mode 100644 index 0000000..e362ade --- /dev/null +++ b/extensions/radio/commands/stations.ts @@ -0,0 +1,86 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { favorites, searchStations, topStations } from "../api.ts"; +import { countryParams, mergeResults, playerChoice, type Station } from "../lib.ts"; +import { distro, findPlayer, nowPlaying, song } from "../player.ts"; +import { noPlayerItem, nowPlayingItem, stationItem } from "../views.ts"; + +type View = "stations" | "country" | "favorites"; + +let playingName: string | undefined; + +const matches = (s: Station, q: string) => + !q || `${s.name} ${s.tags ?? ""} ${s.country ?? ""}`.toLowerCase().includes(q.toLowerCase()); + +export default (ctx: Context) => { + const choices = playerChoice(ctx.preferences.player); + const home = String(ctx.preferences.country ?? "").trim(); + const player = findPlayer(choices); + return list({ + placeholder: "Search stations by name or genre: fip, jazz, lofi, bbc…", + filter: { + options: [ + { value: "stations", title: "Stations" }, + { value: "country", title: "By Country" }, + { value: "favorites", title: "Favorites" }, + ], + }, + empty: { title: "No station found", description: "Try a genre (jazz) or another spelling", icon: Icons.radio }, + status: (items) => { + const n = items.filter((i) => !["now-playing", "no-player", "country-hint"].includes(i.id ?? "")).length; + return [playingName && `Playing ${playingName}`, `${n} station${n === 1 ? "" : "s"}`, "radio-browser.info"] + .filter(Boolean).join(" · "); + }, + async search(query, { signal, filter }) { + const view = (filter ?? "stations") as View; + const q = query.trim(); + const [favs, current, found] = await Promise.all([favorites(), nowPlaying(), player]); + playingName = current?.station.name.trim(); + updateCommandMetadata({ subtitle: current ? `Playing ${current.station.name.trim()}` : null }).catch(() => {}); + const fav = new Set(favs.map((f) => f.stationuuid)); + const item = (s: Station, section?: string) => + stationItem(s, { + favorite: fav.has(s.stationuuid), + playing: current?.station.stationuuid === s.stationuuid, + choices, + section, + }); + + const head: Item[] = []; + if (!found) head.push(noPlayerItem(await distro())); + if (current && !q) head.push(nowPlayingItem(current, current.player === "mpv" ? await song() : undefined)); + + if (view === "favorites") return [...head, ...favs.filter((s) => matches(s, q)).map((s) => item(s, "Favorites"))]; + + if (view === "country") { + const where = q || home; + if (!where) { + return [...head, { + id: "country-hint", + title: "Type a country: France, FR, Japan, US…", + subtitle: "or set your country in the extension's preferences", + icon: { source: Icons.globe, tint: "blue" }, + }]; + } + const stations = await searchStations(countryParams(where), signal, 60); + return [...head, ...stations.map((s) => item(s, `Top in ${stations[0]?.country || where}`))]; + } + + if (!q) { + const top = await topStations(signal); + return [ + ...head, + ...favs.map((s) => item(s, "Favorites")), + ...top.filter((s) => !fav.has(s.stationuuid)).map((s) => item(s, "Top Voted")), + ]; + } + const [byName, byTag] = await Promise.all([ + searchStations({ name: q }, signal), + searchStations({ tag: q.toLowerCase() }, signal).catch((e) => { + if (signal.aborted) throw e; + return [] as Station[]; + }), + ]); + return [...head, ...mergeResults(byName, byTag, q).map((s) => item(s))]; + }, + }); +}; diff --git a/extensions/radio/commands/stop.ts b/extensions/radio/commands/stop.ts new file mode 100644 index 0000000..6d1afd6 --- /dev/null +++ b/extensions/radio/commands/stop.ts @@ -0,0 +1,8 @@ +import { run } from "@backslash/api"; +import { nowPlaying, stop } from "../player.ts"; + +export default run(async () => { + const current = await nowPlaying(); + await stop(); + return current ? `${current.station.name.trim()} stopped` : "Nothing was playing"; +}); diff --git a/extensions/radio/lib.ts b/extensions/radio/lib.ts new file mode 100644 index 0000000..7893546 --- /dev/null +++ b/extensions/radio/lib.ts @@ -0,0 +1,199 @@ +// radio-browser.info answers, player command lines and formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +/** A station of `/json/stations/…`. */ +export interface Station { + stationuuid: string; + name: string; + url: string; + url_resolved?: string; + homepage?: string; + favicon?: string; + tags?: string; + country?: string; + countrycode?: string; + state?: string; + language?: string; + votes?: number; + codec?: string; + bitrate?: number; + hls?: number; + lastcheckok?: number; + clickcount?: number; + clicktrend?: number; +} + +export type Player = "mpv" | "ffplay" | "vlc"; +export const PLAYERS: Player[] = ["mpv", "ffplay", "vlc"]; + +/** The `player` preference: one player, or all in order for auto. */ +export function playerChoice(input: unknown): Player[] { + const p = String(input ?? "").trim().toLowerCase().replace(/^cvlc$/, "vlc"); + return (PLAYERS as string[]).includes(p) ? [p as Player] : PLAYERS; +} + +/** The program run for a player. */ +export const program = (p: Player) => (p === "vlc" ? "cvlc" : p); + +/** The command line that plays `url` without a window. */ +export function playerArgs(p: Player, url: string, o: { script?: string; title?: string } = {}): string[] { + switch (p) { + case "mpv": + return [ + "mpv", + "--no-video", + "--no-terminal", + "--idle=no", + "--cache=yes", + "--demuxer-max-bytes=4MiB", + ...(o.script ? [`--script=${o.script}`] : []), + ...(o.title ? [`--title=${o.title}`] : []), + url, + ]; + case "ffplay": + return [ + "ffplay", + "-nodisp", + "-autoexit", + "-loglevel", + "error", + ...(o.title ? ["-window_title", o.title] : []), + url, + ]; + case "vlc": + return [ + "cvlc", + "--intf", + "dummy", + "--no-video", + "--play-and-exit", + ...(o.title ? ["--meta-title", o.title] : []), + url, + ]; + } +} + +/** `systemd-run` arguments for a background unit running `argv`. */ +export function unitArgs(unit: string, description: string, argv: string[]): string[] { + return ["--user", "--quiet", "--collect", `--unit=${unit}`, `--description=${description}`, ...argv]; +} + +/** Station tags: `"jazz,france, public radio"` → `["jazz", "france", "public radio"]`. */ +export function tags(s: Pick<Station, "tags">, limit = 8): string[] { + const seen = new Set<string>(); + return (s.tags ?? "").split(",").map((t) => t.trim().toLowerCase()).filter((t) => { + if (!t || t.length > 24 || seen.has(t)) return false; + seen.add(t); + return true; + }).slice(0, limit); +} + +/** "AAC+ · 128 kbps", "MP3", "HLS". */ +export function quality(s: Pick<Station, "codec" | "bitrate" | "hls">): string { + const codec = s.codec && s.codec !== "UNKNOWN" ? s.codec : s.hls ? "HLS" : ""; + return [codec, s.bitrate ? `${s.bitrate} kbps` : ""].filter(Boolean).join(" · "); +} + +const GENRES: [RegExp, Color][] = [ + [/jazz|blues|soul|funk/, "purple"], + [/classical|classique|opera|baroque/, "teal"], + [/news|talk|info|actualit|sport|public radio/, "blue"], + [/rock|metal|punk|grunge|indie/, "red"], + [/electro|dance|house|techno|trance|edm|ambient|chill|lounge|lofi|lo-fi/, "pink"], + [/pop|hits|top 40|charts|80s|90s|oldies/, "orange"], + [/country|folk|world|latin|reggae/, "green"], + [/hip.?hop|rap|r&b|urban/, "yellow"], +]; + +/** A color for the station's icon, from its genres. */ +export function genreColor(s: Pick<Station, "tags">): Color { + const t = tags(s, 20).join(" "); + return GENRES.find(([re]) => re.test(t))?.[1] ?? "gray"; +} + +/** What the user typed for the Country view → radio-browser parameters. */ +export function countryParams(query: string): Record<string, string> { + const q = query.trim(); + if (/^[a-z]{2}$/i.test(q)) return { countrycode: q.toUpperCase() }; + return { country: q }; +} + +/** Merges name and tag results: exact and name matches first, then by votes, without duplicates. */ +export function mergeResults(byName: Station[], byTag: Station[], query: string, limit = 60): Station[] { + const q = query.trim().toLowerCase(); + const seen = new Set<string>(); + const all = [...byName, ...byTag].filter((s) => { + if (!s.stationuuid || seen.has(s.stationuuid)) return false; + seen.add(s.stationuuid); + return true; + }); + const score = (s: Station) => { + const n = s.name.trim().toLowerCase(); + return n === q ? 0 : n.startsWith(q) ? 1 : n.includes(q) ? 2 : 3; + }; + return all.map((s, i) => ({ s, i })).sort((a, b) => + score(a.s) - score(b.s) || (b.s.votes ?? 0) - (a.s.votes ?? 0) || a.i - b.i + ).slice(0, limit).map((x) => x.s); +} + +/** + * An mpv script writing the song of the stream's ICY metadata to `out` each + * time it changes (the key's case varies by server), and removing it on exit. + */ +export function songScript(out: string): string { + return [ + `local out = ${JSON.stringify(out)}`, + "local function save(_, m)", + ' local title = ""', + ' if m then for k, v in pairs(m) do if string.lower(k) == "icy-title" then title = v end end end', + ' local f = io.open(out, "w")', + " if f then f:write(title) f:close() end", + "end", + 'mp.observe_property("metadata", "native", save)', + 'mp.register_event("shutdown", function() os.remove(out) end)', + "", + ].join("\n"); +} + +/** The song file's text → the song, ignoring placeholders like " - ". */ +export function songTitle(text: string | undefined): string | undefined { + const value = (text ?? "").trim(); + return value && !/^[\s\-–]*$/.test(value) ? value : undefined; +} + +/** "3 min", "1 h 05": how long it has been playing. */ +export function elapsed(ms: number): string { + const min = Math.floor(ms / 60_000); + if (min < 1) return "just started"; + if (min < 60) return `${min} min`; + return `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, "0")}`; +} + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** Distro family from /etc/os-release, for install hints. */ +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +/** `sudo apt install mpv`, or a generic hint. */ +export function installCommand(family: Family | undefined, pkg = "mpv"): string { + return family ? `${MANAGERS[family]} ${pkg}` : `Install ${pkg} with your package manager`; +} diff --git a/extensions/radio/lib_test.ts b/extensions/radio/lib_test.ts new file mode 100644 index 0000000..66cb2ce --- /dev/null +++ b/extensions/radio/lib_test.ts @@ -0,0 +1,168 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { + countryParams, + elapsed, + genreColor, + installCommand, + mergeResults, + osFamily, + playerArgs, + playerChoice, + program, + quality, + songScript, + songTitle, + type Station, + tags, + unitArgs, +} from "./lib.ts"; +import { noPlayerItem, nowPlayingItem, stationItem } from "./views.ts"; + +// Shaped like `/json/stations/search` results. +const fip: Station = { + stationuuid: "f4ae3a6e-0601-11e8-ae97-52543be04c81", + name: "FIP", + url: "http://icecast.radiofrance.fr/fip-hifi.aac", + url_resolved: "http://icecast.radiofrance.fr/fip-hifi.aac", + homepage: "https://www.radiofrance.fr/fip", + favicon: "https://www.radiofrance.fr/favicon.ico", + tags: "eclectic,jazz,france, public radio,Jazz", + country: "France", + countrycode: "FR", + state: "Paris", + language: "french", + votes: 25123, + codec: "AAC", + bitrate: 192, + hls: 0, + lastcheckok: 1, + clickcount: 3123, + clicktrend: 12, +}; +const soma: Station = { + ...fip, + stationuuid: "9608b51d", + name: "SomaFM Groove Salad", + tags: "ambient,chillout,electronic", + votes: 9000, + codec: "MP3", + bitrate: 128, +}; +const fipJazz: Station = { ...fip, stationuuid: "fip-jazz", name: "FIP Jazz", votes: 3000 }; + +Deno.test("players", () => { + assertEquals(playerChoice("auto"), ["mpv", "ffplay", "vlc"]); + assertEquals(playerChoice(" MPV "), ["mpv"]); + assertEquals(playerChoice("cvlc"), ["vlc"]); + assertEquals(playerChoice(undefined), ["mpv", "ffplay", "vlc"]); + assertEquals(program("vlc"), "cvlc"); + assertEquals( + playerArgs("mpv", "http://s/x.aac", { script: "/h/.cache/backslash/radio/now-playing.lua", title: "FIP" }), + [ + "mpv", + "--no-video", + "--no-terminal", + "--idle=no", + "--cache=yes", + "--demuxer-max-bytes=4MiB", + "--script=/h/.cache/backslash/radio/now-playing.lua", + "--title=FIP", + "http://s/x.aac", + ], + ); + assertEquals(playerArgs("ffplay", "http://s"), ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", "http://s"]); + assertEquals(playerArgs("vlc", "http://s").slice(0, 3), ["cvlc", "--intf", "dummy"]); + assertEquals(unitArgs("backslash-radio", "Backslash Radio: FIP", ["mpv", "u"]), [ + "--user", + "--quiet", + "--collect", + "--unit=backslash-radio", + "--description=Backslash Radio: FIP", + "mpv", + "u", + ]); +}); + +Deno.test("song title script", () => { + const lua = songScript('/home/a "b"/.cache/backslash/radio/song.txt'); + assertStringIncludes(lua, 'local out = "/home/a \\"b\\"/.cache/backslash/radio/song.txt"'); + assertStringIncludes(lua, 'mp.observe_property("metadata", "native", save)'); + assertEquals(songTitle("Bonobo - Kerala\n"), "Bonobo - Kerala"); + assertEquals(songTitle(" - "), undefined); + assertEquals(songTitle(""), undefined); + assertEquals(songTitle(undefined), undefined); +}); + +Deno.test("stations", () => { + assertEquals(tags(fip), ["eclectic", "jazz", "france", "public radio"]); + assertEquals(tags({ tags: "" }), []); + assertEquals(quality(fip), "AAC · 192 kbps"); + assertEquals(quality({ codec: "UNKNOWN", bitrate: 0, hls: 1 }), "HLS"); + assertEquals(quality({ codec: "MP3" }), "MP3"); + assertEquals(genreColor(fip), "purple"); + assertEquals(genreColor(soma), "pink"); + assertEquals(genreColor({ tags: "news,talk" }), "blue"); + assertEquals(genreColor({ tags: "" }), "gray"); + assertEquals(countryParams("fr"), { countrycode: "FR" }); + assertEquals(countryParams("Japan"), { country: "Japan" }); +}); + +Deno.test("merging name and tag results", () => { + assertEquals(mergeResults([fipJazz, fip], [soma, fip], "fip").map((s) => s.name), [ + "FIP", + "FIP Jazz", + "SomaFM Groove Salad", + ]); + assertEquals(mergeResults([], [soma, fipJazz], "jazz").map((s) => s.name), ["FIP Jazz", "SomaFM Groove Salad"]); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily('NAME="Ubuntu"\nID=ubuntu\nID_LIKE=debian\n'), "debian"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); + assertEquals(osFamily('ID="opensuse-tumbleweed"\n'), "suse"); + assertEquals(osFamily("ID=endeavouros\nID_LIKE=arch\n"), "arch"); + assertEquals(osFamily("ID=nixos\n"), undefined); + assertEquals(installCommand("arch"), "sudo pacman -S mpv"); + assertEquals(installCommand(undefined), "Install mpv with your package manager"); + assertEquals(noPlayerItem("fedora").subtitle, "sudo dnf install mpv"); +}); + +Deno.test("formatting", () => { + assertEquals(elapsed(20_000), "just started"); + assertEquals(elapsed(25 * 60_000), "25 min"); + assertEquals(elapsed(65 * 60_000), "1 h 05"); +}); + +Deno.test("station items", () => { + const item = stationItem(fip, { favorite: true, playing: false, choices: ["mpv"] }); + assertEquals(item.title, "FIP"); + assertEquals(item.subtitle, "eclectic, jazz, france"); + assertEquals(item.icon, { source: "bs-radio", tint: "purple" }); + assertEquals(item.accessories?.map((a) => a.text ?? a.tag ?? a.tooltip), [ + "In your favorites", + "AAC · 192 kbps", + "FR", + ]); + assertEquals(item.actions?.map((a) => a.title), [ + "Play", + "Remove from Favorites", + "Open Website", + "Copy Stream URL", + "Copy Name", + ]); + const playing = stationItem(fip, { favorite: false, playing: true, choices: ["mpv"], section: "Top Voted" }); + assertEquals(playing.icon, { source: "bs-speaker", tint: "green" }); + assertEquals(playing.actions?.[0].title, "Stop"); + + const now = nowPlayingItem({ station: fip, player: "mpv", started: Date.now() - 3 * 60_000 }, "Bonobo - Kerala"); + assertEquals([now.title, now.subtitle], ["Bonobo - Kerala", "FIP"]); + assertEquals(now.accessories?.[0].text, "3 min"); + assertEquals(now.actions?.map((a) => a.title), [ + "Stop", + "Copy Song Title", + "Search on YouTube", + "Open Station Website", + ]); + const quiet = nowPlayingItem({ station: fip, player: "ffplay", started: Date.now() }, undefined); + assertEquals([quiet.title, quiet.subtitle], ["FIP", "Playing with ffplay"]); +}); diff --git a/extensions/radio/player.ts b/extensions/radio/player.ts new file mode 100644 index 0000000..b27616a --- /dev/null +++ b/extensions/radio/player.ts @@ -0,0 +1,126 @@ +// Playing a station in the background: a transient systemd user unit running +// mpv (or ffplay, or VLC), so it keeps playing once the launcher is hidden. + +import { exec } from "@backslash/api"; +import { click, load, save } from "./api.ts"; +import { + type Family, + osFamily, + type Player, + playerArgs, + program, + songScript, + songTitle, + type Station, + unitArgs, +} from "./lib.ts"; + +export const UNIT = "backslash-radio"; + +export interface Playing { + station: Station; + player: Player; + started: number; +} + +const home = () => Deno.env.get("HOME") ?? ""; +const dir = () => `${home()}/.cache/backslash/radio`; +const scriptPath = () => `${dir()}/now-playing.lua`; +const songPath = () => `${dir()}/song.txt`; + +/** Whether a program can be started: installed and allowed. */ +async function installed(name: string, args: string[]): Promise<boolean> { + try { + await new Deno.Command(name, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }) + .output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +const VERSION_ARGS: Record<Player, string[]> = { mpv: ["--version"], ffplay: ["-version"], vlc: ["--version"] }; + +/** The first of `choices` that is installed. */ +export async function findPlayer(choices: Player[]): Promise<Player | undefined> { + for (const p of choices) if (await installed(program(p), VERSION_ARGS[p])) return p; + return undefined; +} + +export async function distro(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function unitActive(): Promise<boolean> { + try { + return (await exec("systemctl", ["--user", "is-active", `${UNIT}.service`])) === "active"; + } catch { + return false; // is-active exits 3 when the unit is gone. + } +} + +/** What is playing, if the unit still runs. */ +export async function nowPlaying(): Promise<Playing | undefined> { + const saved = await load<Playing>("playing"); + if (!saved) return undefined; + if (await unitActive()) return saved; + await save("playing", null); + return undefined; +} + +export async function stop(): Promise<boolean> { + const was = await load<Playing>("playing"); + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + await save("playing", null); + return !!was; +} + +export class NoPlayer extends Error {} + +/** Starts the station in the background, replacing what was playing. */ +export async function play(station: Station, choices: Player[]): Promise<Playing> { + const player = await findPlayer(choices); + if (!player) throw new NoPlayer("No player found: install mpv to listen to the radio"); + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + const url = await click(station); + let script: string | undefined; + if (player === "mpv" && home()) { + try { + await Deno.mkdir(dir(), { recursive: true }); + await Deno.remove(songPath()).catch(() => {}); + await Deno.writeTextFile(scriptPath(), songScript(songPath())); + script = scriptPath(); + } catch { + // Plays without the song title. + } + } + const argv = playerArgs(player, url, { script, title: station.name }); + try { + await exec("systemd-run", unitArgs(UNIT, `Backslash Radio: ${station.name}`, argv)); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to play in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: stations play in the background with systemd-run --user"); + } + throw e; + } + // A stream that can't be opened makes the player quit at once. + await new Promise((r) => setTimeout(r, 1200)); + if (!await unitActive()) throw new Error(`${station.name} could not be played: the stream is offline or unsupported`); + const playing = { station, player, started: Date.now() }; + await save("playing", playing); + return playing; +} + +/** The song mpv read from the stream's ICY metadata, if any. */ +export async function song(): Promise<string | undefined> { + if (!home()) return undefined; + return songTitle(await Deno.readTextFile(songPath()).catch(() => undefined)); +} diff --git a/extensions/radio/views.ts b/extensions/radio/views.ts new file mode 100644 index 0000000..371eb84 --- /dev/null +++ b/extensions/radio/views.ts @@ -0,0 +1,198 @@ +// Stations and what's playing as launcher items. + +import { + type Accessory, + type Action, + action, + close, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { toggleFavorite } from "./api.ts"; +import { + compact, + elapsed, + type Family, + genreColor, + installCommand, + type Player, + quality, + type Station, + tags, +} from "./lib.ts"; +import { NoPlayer, play, type Playing, stop } from "./player.ts"; + +const PLAYER_NAMES: Record<Player, string> = { mpv: "mpv", ffplay: "ffplay", vlc: "VLC" }; + +export function playAction(s: Station, choices: Player[], title = "Play"): Action { + return action(title, async () => { + try { + await play(s, choices); + } catch (e) { + if (e instanceof NoPlayer) { + await toast(e.message, { style: "failure" }); + return refresh(); + } + throw e; + } + updateCommandMetadata({ subtitle: `Playing ${s.name}` }).catch(() => {}); + return close(`Playing ${s.name}`); + }, { icon: Icons.play }); +} + +export const stopAction = (name: string) => + action("Stop", async () => { + await stop(); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + await toast(`${name} stopped`, { style: "success" }); + return refresh(); + }, { icon: Icons.stop, shortcut: "<Ctrl>period" }); + +function stationMetadata(s: Station): Metadata[] { + const lines: Metadata[] = []; + const t = tags(s); + if (t.length) lines.push(meta.tags("Genres", t.map((text) => ({ text, color: genreColor({ tags: text }) })))); + const where = [s.state, s.country].filter(Boolean).join(", "); + if (where) lines.push(meta.text("Country", where, { icon: Icons.globe })); + if (s.language) lines.push(meta.text("Language", s.language.split(",").slice(0, 3).join(", "))); + const q = quality(s); + if (q) lines.push(meta.text("Stream", q, { icon: Icons.radio })); + if (s.votes) lines.push(meta.text("Votes", compact(s.votes), { icon: Icons.heart })); + if (s.clickcount) { + lines.push( + meta.text( + "Listeners today", + `${compact(s.clickcount)}${s.clicktrend ? ` (${s.clicktrend > 0 ? "+" : ""}${s.clicktrend})` : ""}`, + { + icon: Icons.activity, + }, + ), + ); + } + if (s.lastcheckok === 0) { + lines.push(meta.text("Status", "Offline at the last check", { color: "red", icon: Icons.alert })); + } + if (s.homepage) { + let host = s.homepage; + try { + host = new URL(s.homepage).hostname.replace(/^www\./, ""); + } catch { + // Shown as is. + } + lines.push(meta.separator(), meta.link("Website", host, s.homepage)); + } + return lines; +} + +export function stationItem( + s: Station, + o: { favorite: boolean; playing: boolean; choices: Player[]; section?: string }, +): Item { + const accessories: Accessory[] = []; + if (o.playing) accessories.push({ tag: "Playing", color: "green" }); + if (o.favorite && o.section !== "Favorites") { + accessories.push({ icon: { source: Icons.heartFilled, tint: "pink" }, tooltip: "In your favorites" }); + } + const q = quality(s); + if (q) accessories.push({ text: q, tooltip: "Codec and bitrate" }); + if (s.countrycode) accessories.push({ tag: s.countrycode, tooltip: s.country }); + const t = tags(s, 4); + return { + id: `${o.section ?? ""}:${s.stationuuid}`, + title: s.name.trim(), + subtitle: t.slice(0, 3).join(", "), + section: o.section, + icon: { source: o.playing ? Icons.speaker : Icons.radio, tint: o.playing ? "green" : genreColor(s) }, + keywords: [...t, s.country ?? "", s.countrycode ?? ""], + accessories, + detail: { + stats: [ + ...(s.bitrate ? [{ label: "Bitrate", value: `${s.bitrate} kbps` }] : []), + ...(s.votes ? [{ label: "Votes", value: compact(s.votes) }] : []), + ...(s.countrycode ? [{ label: "Country", value: s.countrycode }] : []), + ], + markdown: `**${s.name.trim()}**`, + metadata: stationMetadata(s), + }, + actions: [ + o.playing ? stopAction(s.name) : playAction(s, o.choices), + action(o.favorite ? "Remove from Favorites" : "Add to Favorites", async () => { + const now = await toggleFavorite(s); + await toast(`${s.name} ${now ? "added to" : "removed from"} favorites`, { style: "success" }); + return refresh(); + }, { icon: o.favorite ? Icons.heartFilled : Icons.heart, shortcut: "<Ctrl>s" }), + ...(s.homepage ? [open(s.homepage, "Open Website", { shortcut: "<Ctrl>o" })] : []), + copy(s.url_resolved || s.url, "Copy Stream URL", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(s.name.trim(), "Copy Name", { section: "Copy" }), + ], + }; +} + +export function nowPlayingItem(p: Playing, song: string | undefined): Item { + const s = p.station; + const search = song ?? s.name; + return { + id: "now-playing", + title: song ?? s.name.trim(), + subtitle: song ? s.name.trim() : `Playing with ${PLAYER_NAMES[p.player]}`, + section: "Now Playing", + icon: { source: Icons.speaker, tint: "green" }, + accessories: [{ text: elapsed(Date.now() - p.started), icon: Icons.clock, tooltip: "Playing for" }], + detail: { + markdown: song ? `**${song}**\n\non ${s.name.trim()}` : `**${s.name.trim()}**`, + metadata: [ + meta.text("Station", s.name.trim(), { icon: Icons.radio }), + ...(song ? [meta.text("Song", song, { icon: Icons.music })] : []), + meta.text("Player", PLAYER_NAMES[p.player], { icon: Icons.speaker }), + meta.text("Started", new Date(p.started).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ...stationMetadata(s), + ], + }, + actions: [ + stopAction(s.name), + ...(song ? [copy(song, "Copy Song Title", { shortcut: "<Ctrl><Shift>c" })] : []), + open(`https://www.youtube.com/results?search_query=${encodeURIComponent(search)}`, "Search on YouTube", { + icon: Icons.search, + section: "Open", + }), + ...(s.homepage ? [open(s.homepage, "Open Station Website", { section: "Open" })] : []), + ], + }; +} + +export function noPlayerItem(family: Family | undefined): Item { + const cmd = installCommand(family, "mpv"); + return { + id: "no-player", + title: "Install mpv to listen", + subtitle: family ? cmd : "mpv, ffplay (ffmpeg) or VLC", + section: "Setup", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: [ + "## No audio player found", + "", + "Stations play in the background with **mpv** (recommended: it also shows the song title),", + "**ffplay** (part of FFmpeg) or **VLC**. Install one:", + "", + "```", + cmd, + "```", + "", + "Then pick it in **Settings → Extensions → Internet Radio → Configure**, or leave `auto`.", + ].join("\n"), + }, + actions: family + ? [copy(cmd, "Copy Install Command")] + : [open("https://mpv.io/installation/", "How to Install mpv")], + }; +} diff --git a/extensions/raindrop/api.ts b/extensions/raindrop/api.ts new file mode 100644 index 0000000..98a3a83 --- /dev/null +++ b/extensions/raindrop/api.ts @@ -0,0 +1,67 @@ +// Calls to the Raindrop.io REST API with the user's test token. + +import { storage } from "@backslash/api"; +import { API, type Collection, type Folder, folders, type Raindrop, raindropError, UNSORTED } from "./lib.ts"; + +export async function raindrop<T>( + token: string, + path: string, + options: { method?: string; body?: unknown; signal?: AbortSignal } = {}, +): Promise<T> { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Raindrop.io is unreachable: ${(e as Error).message}`); + } + const json = await res.json().catch(() => undefined) as { result?: boolean; errorMessage?: string } | undefined; + if (!res.ok || json?.result === false) throw raindropError(res.ok ? 400 : res.status, json); + return json as T; +} + +/** Every collection, as paths ("Dev › Rust"). */ +export async function loadFolders(token: string, signal?: AbortSignal): Promise<Folder[]> { + const [roots, children] = await Promise.all([ + raindrop<{ items: Collection[] }>(token, "/collections", { signal }), + raindrop<{ items: Collection[] }>(token, "/collections/childrens", { signal }), + ]); + const all = folders(roots.items, children.items); + await storage.set("folders", all).catch(() => {}); + return all; +} + +/** The collections saved last time (for the chips, at once), else loaded now. */ +export async function cachedFolders(token: string): Promise<Folder[]> { + const saved = await storage.get<Folder[]>("folders").catch(() => undefined); + if (saved?.length) return saved; + return await loadFolders(token).catch(() => []); +} + +export async function save( + token: string, + bookmark: { link: string; collection?: number; title?: string; tags?: string[]; note?: string }, +): Promise<Raindrop> { + const { item } = await raindrop<{ item: Raindrop }>(token, "/raindrop", { + method: "POST", + body: { + link: bookmark.link, + collection: { $id: bookmark.collection ?? UNSORTED }, + ...(bookmark.title ? { title: bookmark.title } : {}), + ...(bookmark.tags?.length ? { tags: bookmark.tags } : {}), + ...(bookmark.note ? { note: bookmark.note } : {}), + // Raindrop fetches the title, excerpt and cover in the background. + pleaseParse: {}, + }, + }); + return item; +} diff --git a/extensions/raindrop/backslash.toml b/extensions/raindrop/backslash.toml new file mode 100644 index 0000000..a7f18da --- /dev/null +++ b/extensions/raindrop/backslash.toml @@ -0,0 +1,40 @@ +[extension] +id = "raindrop" +title = "Raindrop.io" +description = "Search your Raindrop.io bookmarks with their covers, tags and collection; save links, favorite, move and delete" +version = "1.0.0" +author = "Backslash" +icon = "bs-bookmark" +license = "MIT" + +# The REST API, and rdl.ink: Raindrop's own image proxy, which serves every +# cover (whatever site it comes from) from one host. +# No clipboard-read: paste a link in the search field instead (Ctrl+V), so the +# extension never reads what else is on the clipboard. +[permissions] +net = ["api.raindrop.io", "rdl.ink"] + +[[commands]] +name = "search" +title = "Search Raindrop Bookmarks" +description = "Across collections, with Raindrop's operators (#tag, type:article); paste a link to save it" +icon = "bs-bookmark" +keywords = ["raindrop", "bookmarks", "links", "read later", "favoris", "marque-pages", "signets", "liens"] + +[[commands]] +name = "save" +title = "Save Link to Raindrop" +description = "Saves a link to Unsorted; Raindrop fills in the title and cover" +icon = "bs-plus" +mode = "no-view" +keywords = ["raindrop", "save", "add", "bookmark", "link", "enregistrer", "ajouter", "favori"] +[[commands.arguments]] +name = "url" +placeholder = "https://…" +required = true + +[[preferences]] +name = "token" +title = "Test token" +description = "app.raindrop.io/settings/integrations → Create new app → Create test token" +type = "secret" diff --git a/extensions/raindrop/commands/save.ts b/extensions/raindrop/commands/save.ts new file mode 100644 index 0000000..e6212c9 --- /dev/null +++ b/extensions/raindrop/commands/save.ts @@ -0,0 +1,17 @@ +import { run } from "@backslash/api"; +import { save } from "../api.ts"; +import { AuthError, linkIn, tokenOf } from "../lib.ts"; + +export default run<{ url: string }>(async (ctx) => { + const link = linkIn(String(ctx.args.url ?? "")); + if (!link) throw new Error("That's not a link: https://example.com/page"); + let token: string; + try { + token = tokenOf(ctx.preferences); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Search Raindrop Bookmarks to see how`); + throw e; + } + await save(token, { link }); + return `Saved ${new URL(link).host.replace(/^www\./, "")} to Unsorted`; +}); diff --git a/extensions/raindrop/commands/search.ts b/extensions/raindrop/commands/search.ts new file mode 100644 index 0000000..d0b6d94 --- /dev/null +++ b/extensions/raindrop/commands/search.ts @@ -0,0 +1,61 @@ +import { type Context, Icons, list } from "@backslash/api"; +import { cachedFolders, loadFolders, raindrop } from "../api.ts"; +import { AuthError, filterOptions, type Folder, linkIn, type Raindrop, setupItem, tokenOf } from "../lib.ts"; +import { bookmarkItem, saveItem } from "../views.ts"; + +let total = 0; + +export default async (ctx: Context) => { + let token = ""; + let all: Folder[] = []; + try { + token = tokenOf(ctx.preferences); + // The chips come from the collections saved last time; refreshed for the next time. + all = await cachedFolders(token); + loadFolders(token).then((fresh) => (all = fresh)).catch(() => {}); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + } + return list({ + placeholder: "Search bookmarks, #tag, type:article… or paste a link to save it", + filter: all.length ? { options: filterOptions(all) } : undefined, + empty: { + title: "No bookmark found", + description: "Raindrop.io searches titles, notes, tags and pages.", + icon: Icons.bookmark, + }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const shown = items.filter((i) => i.id !== "save").length; + const n = `${total} bookmark${total === 1 ? "" : "s"}`; + return shown < total ? `${shown} of ${n}` : n; + }, + async search(query, { signal, filter }) { + let t: string; + try { + t = tokenOf(ctx.preferences); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } + const collection = Number(filter ?? 0) || 0; + const link = linkIn(query); + const params = new URLSearchParams({ perpage: "50", page: "0", sort: query.trim() ? "score" : "-created" }); + if (query.trim()) params.set("search", link ?? query.trim()); + try { + const res = await raindrop<{ items: Raindrop[]; count: number }>(t, `/raindrops/${collection}?${params}`, { + signal, + }); + total = res.count; + const items = res.items.map((r) => bookmarkItem(t, r, all, { showCollection: collection === 0 })); + if (!link) return items; + const existing = res.items.find((r) => r.link.replace(/\/$/, "") === link.replace(/\/$/, "")); + const target = collection > 0 || collection === -1 ? collection : -1; + return [saveItem(t, link, all, target, existing), ...items.filter((i) => i.id !== String(existing?._id))]; + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } + }, + }); +}; diff --git a/extensions/raindrop/lib.ts b/extensions/raindrop/lib.ts new file mode 100644 index 0000000..03ec26d --- /dev/null +++ b/extensions/raindrop/lib.ts @@ -0,0 +1,256 @@ +// Raindrop.io: token, response types and pure mapping (collections, covers, links). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.raindrop.io/rest/v1"; +export const TOKEN_URL = "https://app.raindrop.io/settings/integrations"; +export const UNSORTED = -1; +export const TRASH = -99; + +/** The token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export function tokenOf(prefs: Record<string, unknown>): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Raindrop.io account"); + return token; +} + +export function raindropError(status: number, body: unknown): Error { + const b = body as { errorMessage?: string; error?: string } | undefined; + const message = b?.errorMessage ?? (typeof b?.error === "string" ? b.error : ""); + if (status === 401) return new AuthError("Raindrop.io rejected the token"); + if (status === 429) return new Error("Raindrop.io is rate limiting: try again in a minute"); + if (status === 404) return new Error("Not found on Raindrop.io: it may have been deleted"); + return new Error(`Raindrop.io answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Collection { + _id: number; + title: string; + count?: number; + color?: string | null; + parent?: { $id: number } | null; + sort?: number; +} + +export interface Raindrop { + _id: number; + title: string; + excerpt?: string; + note?: string; + link: string; + domain?: string; + cover?: string; + type?: string; + tags?: string[]; + created?: string; + lastUpdate?: string; + important?: boolean; + broken?: boolean; + collection?: { $id: number }; + collectionId?: number; + highlights?: { text: string }[]; +} + +// --------------------------------------------------------------------------- +// Collections + +// A type, not an interface, so that it can be kept in storage as JSON. +export type Folder = { + id: number; + title: string; + /** "Dev › Rust". */ + path: string; + count: number; + color: Color; + root: boolean; +}; + +/** Roots and children as one list of paths, roots by Raindrop's order, children under their parent. */ +export function folders(roots: Collection[], children: Collection[]): Folder[] { + const byParent = new Map<number, Collection[]>(); + for (const c of children) { + const p = c.parent?.$id; + if (p === undefined) continue; + byParent.set(p, [...(byParent.get(p) ?? []), c]); + } + const out: Folder[] = []; + const walk = (c: Collection, prefix: string, depth: number) => { + const path = prefix ? `${prefix} › ${c.title}` : c.title; + out.push({ id: c._id, title: c.title, path, count: c.count ?? 0, color: colorOf(c.color), root: depth === 0 }); + if (depth > 5) return; + for (const child of (byParent.get(c._id) ?? []).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) { + walk(child, path, depth + 1); + } + }; + for (const r of [...roots].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))) walk(r, "", 0); + return out; +} + +export function collectionName(id: number | undefined, all: Folder[]): string { + if (id === UNSORTED) return "Unsorted"; + if (id === TRASH) return "Trash"; + return all.find((f) => f.id === id)?.path ?? "Unknown collection"; +} + +/** The chips: all, unsorted, then the biggest top-level collections. */ +export function filterOptions(all: Folder[], max = 6): { value: string; title: string }[] { + const roots = all.filter((f) => f.root).sort((a, b) => b.count - a.count).slice(0, max); + return [ + { value: "0", title: "All" }, + { value: String(UNSORTED), title: "Unsorted" }, + ...roots.map((f) => ({ value: String(f.id), title: f.title })), + ]; +} + +/** The palette color closest to a `#rrggbb` color. */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "blue"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +// --------------------------------------------------------------------------- +// Bookmarks + +export const collectionOf = (r: Raindrop) => r.collection?.$id ?? r.collectionId; + +/** + * Covers come from any site; Raindrop's proxy (rdl.ink) serves them all from + * one host, resized. Covers already on rdl.ink are used as they are. + */ +export function coverUrl(cover: string | undefined, width = 640, height = 360): string | undefined { + if (!cover || !/^https?:\/\//i.test(cover)) return undefined; + if (/^https:\/\/rdl\.ink\//.test(cover)) return cover; + return `https://rdl.ink/render/${encodeURIComponent(cover)}?mode=crop&width=${width}&height=${height}&dpr=2`; +} + +export const TYPES: Record<string, { icon: string; tint: Color; name: string }> = { + link: { icon: Icons.link, tint: "blue", name: "Link" }, + article: { icon: Icons.text, tint: "teal", name: "Article" }, + image: { icon: Icons.image, tint: "pink", name: "Image" }, + video: { icon: Icons.video, tint: "red", name: "Video" }, + document: { icon: Icons.file, tint: "orange", name: "Document" }, + audio: { icon: Icons.music, tint: "purple", name: "Audio" }, +}; + +export const typeOf = (r: Raindrop) => TYPES[r.type ?? "link"] ?? TYPES.link; + +export function domainOf(r: Raindrop): string { + if (r.domain) return r.domain.replace(/^www\./, ""); + try { + return new URL(r.link).host.replace(/^www\./, ""); + } catch { + return r.link; + } +} + +/** A link typed or pasted in the search field: https://…, or a bare domain with a path. */ +export function linkIn(query: string): string | undefined { + const q = query.trim(); + if (!q || /\s/.test(q)) return undefined; + const candidate = /^https?:\/\//i.test(q) ? q : /^[a-z0-9-]+(\.[a-z0-9-]+)+(\/\S*)?$/i.test(q) ? `https://${q}` : ""; + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.hostname.includes(".") ? url.href : undefined; + } catch { + return undefined; + } +} + +/** "a, #b c" → ["a", "b", "c"]. */ +export function parseTags(text: string): string[] { + return [...new Set(text.split(/[,\s]+/).map((t) => t.replace(/^#/, "").trim()).filter(Boolean))]; +} + +/** "today", "yesterday", "3d", "Oct 25", "Oct 2024". */ +export function savedAgo(iso: string | undefined, now = new Date()): string { + if (!iso) return ""; + const d = new Date(iso); + const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.round((day(now) - day(d)) / 86_400_000); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString("en", { month: "short", day: "numeric" }); + return d.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +export function bookmarkMarkdown(r: Raindrop): string | undefined { + const parts: string[] = []; + if (r.excerpt?.trim()) parts.push(r.excerpt.trim()); + if (r.note?.trim()) parts.push(r.note.trim().split("\n").map((l) => `> ${l}`).join("\n")); + const hl = r.highlights ?? []; + if (hl.length) { + parts.push( + `**Highlights**\n\n${hl.slice(0, 3).map((h) => `- “${h.text.trim().slice(0, 200)}”`).join("\n")}${ + hl.length > 3 ? `\n- …and ${hl.length - 3} more` : "" + }`, + ); + } + return parts.join("\n\n") || undefined; +} + +export function bookmarkMetadata(r: Raindrop, all: Folder[], now = new Date()): Metadata[] { + const type = typeOf(r); + const id = collectionOf(r); + const folder = all.find((f) => f.id === id); + const lines: Metadata[] = [ + meta.link("Link", domainOf(r), r.link), + meta.text("Collection", collectionName(id, all), { + icon: id === UNSORTED ? Icons.inbox : Icons.folder, + color: folder?.color, + }), + ]; + if (r.tags?.length) lines.push(meta.tags("Tags", r.tags.map((t) => ({ text: `#${t}`, color: "blue" as Color })))); + lines.push(meta.text("Type", type.name, { icon: type.icon })); + if (r.important) lines.push(meta.text("Favorite", "Yes", { icon: Icons.heartFilled, color: "red" })); + if (r.broken) lines.push(meta.text("Status", "Broken link", { icon: Icons.alert, color: "red" })); + if (r.highlights?.length) lines.push(meta.text("Highlights", r.highlights.length, { icon: Icons.sparkle })); + if (r.created) { + lines.push(meta.separator(), meta.text("Saved", `${savedAgo(r.created, now)}`, { icon: Icons.clock })); + } + return lines; +} + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A test token from Raindrop.io", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Raindrop.io", + "", + "1. Open **app.raindrop.io → Settings → Integrations** (Enter opens it).", + "2. Under **For Developers**, choose **Create new app** (any name, e.g. *Backslash*) and open it.", + "3. Choose **Create test token** and copy it.", + "4. Paste it in **Settings → Extensions → Raindrop.io → Configure → Test token**.", + "", + "A test token gives access to your own bookmarks only; delete the app to revoke it.", + ].join("\n"), + }, + actions: [open(TOKEN_URL, "Create Test Token", { icon: Icons.key })], + }; +} diff --git a/extensions/raindrop/lib_test.ts b/extensions/raindrop/lib_test.ts new file mode 100644 index 0000000..7b681f2 --- /dev/null +++ b/extensions/raindrop/lib_test.ts @@ -0,0 +1,127 @@ +import { assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + AuthError, + bookmarkMarkdown, + bookmarkMetadata, + type Collection, + collectionName, + coverUrl, + domainOf, + filterOptions, + folders, + linkIn, + parseTags, + type Raindrop, + raindropError, + savedAgo, + setupItem, + tokenOf, + typeOf, +} from "./lib.ts"; + +Deno.test("token and errors", () => { + assertThrows(() => tokenOf({}), AuthError, "Connect"); + assertEquals(tokenOf({ token: " ae2e3f-123 " }), "ae2e3f-123"); + assertInstanceOf(raindropError(401, { result: false, errorMessage: "Unauthorized" }), AuthError); + assertEquals( + raindropError(400, { result: false, errorMessage: "link is required" }).message, + "Raindrop.io answered 400: link is required", + ); + assertStringIncludes(setupItem(new AuthError("x")).detail!.markdown!, "Create test token"); +}); + +const roots: Collection[] = [ + { _id: 10, title: "Dev", count: 120, sort: 1, color: "#3f88ff" }, + { _id: 20, title: "Recipes", count: 40, sort: 0, color: "#e44a4a" }, + { _id: 30, title: "Travel", count: 3, sort: 2 }, +]; +const children: Collection[] = [ + { _id: 11, title: "Rust", count: 30, parent: { $id: 10 }, sort: 1 }, + { _id: 12, title: "TypeScript", count: 25, parent: { $id: 10 }, sort: 0 }, + { _id: 13, title: "Async", count: 4, parent: { $id: 11 } }, +]; + +Deno.test("collections as paths, chips", () => { + const all = folders(roots, children); + assertEquals(all.map((f) => f.path), [ + "Recipes", + "Dev", + "Dev › TypeScript", + "Dev › Rust", + "Dev › Rust › Async", + "Travel", + ]); + assertEquals(all.find((f) => f.id === 20)?.color, "red"); + assertEquals(collectionName(13, all), "Dev › Rust › Async"); + assertEquals(collectionName(-1, all), "Unsorted"); + assertEquals(collectionName(-99, all), "Trash"); + assertEquals(filterOptions(all, 2).map((o) => o.title), ["All", "Unsorted", "Dev", "Recipes"]); + assertEquals(filterOptions(all, 2).map((o) => o.value), ["0", "-1", "10", "20"]); +}); + +Deno.test("covers go through Raindrop's proxy", () => { + assertEquals( + coverUrl("https://images.example.com/og.png?x=1", 640, 360), + "https://rdl.ink/render/https%3A%2F%2Fimages.example.com%2Fog.png%3Fx%3D1?mode=crop&width=640&height=360&dpr=2", + ); + assertEquals(coverUrl("https://rdl.ink/render/abc?mode=crop"), "https://rdl.ink/render/abc?mode=crop"); + assertEquals(coverUrl(""), undefined); + assertEquals(coverUrl("data:image/png;base64,xx"), undefined); +}); + +Deno.test("links typed in the search field", () => { + assertEquals(linkIn("https://deno.com/blog"), "https://deno.com/blog"); + assertEquals(linkIn(" deno.com/blog/v2 "), "https://deno.com/blog/v2"); + assertEquals(linkIn("example.org"), "https://example.org/"); + assertEquals(linkIn("rust async"), undefined); + assertEquals(linkIn("#rust"), undefined); + assertEquals(linkIn("type:article"), undefined); + assertEquals(linkIn("http://localhost"), undefined); + assertEquals(linkIn(""), undefined); +}); + +Deno.test("tags, dates, types, domains", () => { + assertEquals(parseTags("reading, #rust design,rust"), ["reading", "rust", "design"]); + assertEquals(parseTags(""), []); + const now = new Date(2026, 9, 27, 14); + assertEquals(savedAgo(new Date(2026, 9, 27, 9).toISOString(), now), "today"); + assertEquals(savedAgo(new Date(2026, 9, 26, 9).toISOString(), now), "yesterday"); + assertEquals(savedAgo(new Date(2026, 9, 22).toISOString(), now), "5d"); + assertEquals(savedAgo(new Date(2026, 1, 3).toISOString(), now), "Feb 3"); + assertEquals(savedAgo(new Date(2024, 1, 3).toISOString(), now), "Feb 2024"); + assertEquals(typeOf({ _id: 1, title: "", link: "", type: "video" }).name, "Video"); + assertEquals(typeOf({ _id: 1, title: "", link: "", type: "weird" }).name, "Link"); + assertEquals(domainOf({ _id: 1, title: "", link: "https://www.example.com/a" }), "example.com"); + assertEquals(domainOf({ _id: 1, title: "", link: "x", domain: "www.deno.com" }), "deno.com"); +}); + +Deno.test("bookmark detail", () => { + const all = folders(roots, children); + const r: Raindrop = { + _id: 1, + title: "Async Rust", + link: "https://rust-lang.github.io/async-book/", + domain: "rust-lang.github.io", + excerpt: "The async book.", + note: "Read chapter 3\nthen 4", + tags: ["rust", "async"], + type: "article", + important: true, + collection: { $id: 13 }, + created: new Date().toISOString(), + highlights: [{ text: "Futures are lazy" }, { text: "a" }, { text: "b" }, { text: "c" }], + }; + assertEquals( + bookmarkMarkdown(r), + "The async book.\n\n> Read chapter 3\n> then 4\n\n**Highlights**\n\n- “Futures are lazy”\n- “a”\n- “b”\n- …and 1 more", + ); + const lines = bookmarkMetadata(r, all); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Link")?.link, r.link); + assertEquals(by("Collection")?.value, "Dev › Rust › Async"); + assertEquals(by("Tags")?.tags?.map((t) => t.text), ["#rust", "#async"]); + assertEquals(by("Type")?.value, "Article"); + assertEquals(by("Favorite")?.value, "Yes"); + assertEquals(by("Highlights")?.value, "4"); + assertEquals(bookmarkMarkdown({ _id: 2, title: "", link: "" }), undefined); +}); diff --git a/extensions/raindrop/views.ts b/extensions/raindrop/views.ts new file mode 100644 index 0000000..aee6e34 --- /dev/null +++ b/extensions/raindrop/views.ts @@ -0,0 +1,195 @@ +// Items, actions and the save form. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + meta, + open, + refresh, + replace, + submenu, + toast, +} from "@backslash/api"; +import { raindrop, save } from "./api.ts"; +import { + bookmarkMarkdown, + bookmarkMetadata, + collectionName, + collectionOf, + coverUrl, + domainOf, + type Folder, + parseTags, + type Raindrop, + savedAgo, + typeOf, + UNSORTED, +} from "./lib.ts"; + +export const appUrl = (r: Raindrop) => `https://app.raindrop.io/my/${collectionOf(r) ?? 0}/item/${r._id}/edit`; + +async function update(token: string, r: Raindrop, body: Record<string, unknown>, message: string) { + await raindrop(token, `/raindrop/${r._id}`, { method: "PUT", body }); + await toast(message, { style: "success" }); + return refresh(); +} + +export function bookmarkActions(token: string, r: Raindrop, all: Folder[]): Action[] { + const here = collectionOf(r); + return [ + open(r.link, "Open Link"), + open(appUrl(r), "Open in Raindrop.io", { icon: Icons.bookmark, shortcut: "<Ctrl>o" }), + action( + r.important ? "Remove from Favorites" : "Add to Favorites", + () => + update(token, r, { important: !r.important }, r.important ? "Removed from favorites" : "Added to favorites"), + { icon: r.important ? Icons.heart : Icons.heartFilled, shortcut: "<Ctrl>f", section: "Bookmark" }, + ), + submenu( + "Move to Collection", + [ + ...(here === UNSORTED ? [] : [{ id: UNSORTED, path: "Unsorted" }]), + ...all.filter((f) => f.id !== here), + ].map((f) => + action(f.path, () => update(token, r, { collection: { $id: f.id } }, `Moved to ${f.path}`), { + icon: f.id === UNSORTED ? Icons.inbox : Icons.folder, + }) + ), + { icon: Icons.folder, shortcut: "<Ctrl>m", section: "Bookmark" }, + ), + copy(r.link, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(`[${r.title || domainOf(r)}](${r.link})`, "Copy as Markdown Link", { section: "Copy" }), + copy(r.title || r.link, "Copy Title", { shortcut: "<Ctrl>period", section: "Copy" }), + action("Delete Bookmark", async () => { + await raindrop(token, `/raindrop/${r._id}`, { method: "DELETE" }); + await toast("Moved to Trash", { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Move “${r.title || domainOf(r)}” to the Trash?`, + shortcut: "<Ctrl><Shift>Delete", + section: "Danger", + }), + ]; +} + +export function bookmarkItem(token: string, r: Raindrop, all: Folder[], options: { showCollection: boolean }): Item { + const type = typeOf(r); + const id = collectionOf(r); + const folder = all.find((f) => f.id === id); + const tags = r.tags ?? []; + return { + id: String(r._id), + title: r.title || domainOf(r), + subtitle: domainOf(r), + icon: { source: type.icon, tint: type.tint }, + keywords: [...tags, r.link, collectionName(id, all)], + accessories: [ + ...(r.important ? [{ icon: { source: Icons.heartFilled, tint: "red" as const }, tooltip: "Favorite" }] : []), + ...(r.broken ? [{ icon: { source: Icons.alert, tint: "red" as const }, tooltip: "Broken link" }] : []), + ...tags.slice(0, 2).map((t) => ({ text: `#${t}`, color: "blue" as const })), + ...(options.showCollection + ? [{ + icon: { source: id === UNSORTED ? Icons.inbox : Icons.folder, tint: folder?.color ?? "gray" }, + text: collectionName(id, all).split(" › ").pop(), + tooltip: collectionName(id, all), + }] + : []), + ...(r.created ? [{ text: savedAgo(r.created), tooltip: "Saved" }] : []), + ], + detail: { + image: coverUrl(r.cover), + markdown: bookmarkMarkdown(r), + metadata: bookmarkMetadata(r, all), + }, + actions: bookmarkActions(token, r, all), + }; +} + +function savedPage(r: Raindrop, all: Folder[]) { + return detail({ + title: "Saved", + markdown: `# ${r.title || domainOf(r)}\n\nSaved to **${collectionName(collectionOf(r), all)}**. ` + + "Raindrop.io fills in the title, excerpt and cover in a moment.", + metadata: [ + meta.link("Link", domainOf(r), r.link), + meta.text("Collection", collectionName(collectionOf(r), all), { icon: Icons.folder }), + ...(r.tags?.length ? [meta.tags("Tags", r.tags.map((t) => `#${t}`))] : []), + ], + status: "Bookmark saved", + actions: [ + open(appUrl(r), "Open in Raindrop.io", { icon: Icons.bookmark }), + open(r.link, "Open Link"), + copy(r.link, "Copy Link", { shortcut: "<Ctrl><Shift>c" }), + ], + }); +} + +export function saveForm(token: string, link: string, all: Folder[], collection = UNSORTED) { + return form({ + title: "Save to Raindrop.io", + submitTitle: "Save Bookmark", + fields: [ + field.text("link", "Link", { required: true, value: link }), + field.text("title", "Title", { placeholder: "Leave empty: Raindrop.io reads it from the page" }), + field.dropdown( + "collection", + "Collection", + [{ value: String(UNSORTED), title: "Unsorted" }, ...all.map((f) => ({ value: String(f.id), title: f.path }))], + { value: String(collection) }, + ), + field.text("tags", "Tags", { placeholder: "reading, rust, design" }), + field.textArea("note", "Note"), + ], + async submit(values) { + const r = await save(token, { + link: String(values.link).trim(), + collection: Number(values.collection), + title: String(values.title ?? "").trim() || undefined, + tags: parseTags(String(values.tags ?? "")), + note: String(values.note ?? "").trim() || undefined, + }); + await toast("Saved to Raindrop.io", { style: "success" }); + // The search below doesn't reload on its own: show what was saved instead. + return replace(savedPage(r, all)); + }, + }); +} + +/** The first row when the search field holds a link. */ +export function saveItem(token: string, link: string, all: Folder[], collection: number, existing?: Raindrop): Item { + const where = collectionName(collection, all); + if (existing) { + return { + ...bookmarkItem(token, existing, all, { showCollection: true }), + id: "save", + section: "Already Saved", + }; + } + return { + id: "save", + title: `Save ${link.replace(/^https?:\/\//, "").replace(/\/$/, "")}`, + subtitle: `to ${where}`, + icon: { source: Icons.plus, tint: "green" }, + section: "Save", + actions: [ + action("Save Bookmark", async () => { + const r = await save(token, { link, collection }); + await toast(`Saved to ${collectionName(collectionOf(r) ?? collection, all)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.bookmark }), + action("Save with Details…", () => saveForm(token, link, all, collection), { + icon: Icons.settings, + shortcut: "<Ctrl>e", + }), + open(link, "Open Link"), + ], + }; +} diff --git a/extensions/random/backslash.toml b/extensions/random/backslash.toml new file mode 100644 index 0000000..560a080 --- /dev/null +++ b/extensions/random/backslash.toml @@ -0,0 +1,72 @@ +[extension] +id = "random" +title = "Random" +description = "Flip a coin, roll dice, draw a number, pick or shuffle from a list, get a color or ask the Magic 8-Ball" +version = "1.0.0" +author = "Backslash" +icon = "bs-dice" +license = "MIT" + +# Everything runs locally with crypto.getRandomValues. The locale variables +# pick English or French answers when the language preference is "auto". +[permissions] +env = ["LANG", "LC_ALL", "LC_MESSAGES"] + +[[commands]] +name = "random" +title = "Random" +description = "Type 2d6+3, 1-100, pick a, b, c, shuffle…, coin, color or a question" +icon = "bs-dice" +keywords = [ + "random", + "dice", + "roll", + "coin", + "flip", + "number", + "pick", + "choose", + "shuffle", + "color", + "8ball", + "magic 8 ball", + "yes no", + "aléatoire", + "hasard", + "dé", + "dés", + "pile ou face", + "nombre", + "tirage", + "tirer au sort", + "choisir", + "mélanger", + "couleur", + "oui non", + "boule magique", +] + +[[commands]] +name = "flip-coin" +title = "Flip a Coin" +description = "Heads or tails, copied" +icon = "bs-coin" +mode = "no-view" +keywords = ["coin", "flip", "heads", "tails", "pile ou face", "pièce", "random", "hasard"] + +[[commands]] +name = "roll-dice" +title = "Roll Dice" +description = "e.g. 2d6+3, d20, 4d6kh3; the total is copied" +icon = "bs-dice" +mode = "no-view" +keywords = ["dice", "roll", "d20", "d6", "dé", "dés", "lancer", "random"] +[[commands.arguments]] +name = "dice" +placeholder = "1d6" + +[[preferences]] +name = "language" +title = "Language of the answers" +description = "auto (from your locale and what you type), en or fr" +default = "auto" diff --git a/extensions/random/commands/flip-coin.ts b/extensions/random/commands/flip-coin.ts new file mode 100644 index 0000000..be50e5f --- /dev/null +++ b/extensions/random/commands/flip-coin.ts @@ -0,0 +1,18 @@ +import { clipboard, run } from "@backslash/api"; +import { COIN, language, randomInt } from "../lib.ts"; + +const env = (k: string) => { + try { + return Deno.env.get(k); + } catch { + return undefined; + } +}; + +// Flashes the face and copies it. +export default run(async ({ preferences }) => { + const lang = language((preferences as { language?: string }).language, env("LC_ALL") || env("LANG")); + const face = COIN[lang][randomInt(0, 1)]; + await clipboard.write(face); + return face; +}); diff --git a/extensions/random/commands/random.ts b/extensions/random/commands/random.ts new file mode 100644 index 0000000..78e71c4 --- /dev/null +++ b/extensions/random/commands/random.ts @@ -0,0 +1,460 @@ +import { + type Action, + action, + type Color, + type Context, + copy, + Icons, + type Item, + list, + meta, + paste, + refresh, +} from "@backslash/api"; +import { + COIN, + diceNotation, + drawInRange, + EIGHT_BALL, + formatNumber, + hex, + hsl, + type Intent, + type Lang, + language, + looksFrench, + luminance, + type Mood, + parseIntent, + pick, + randomColor, + randomInt, + roll, + rollBreakdown, + shuffle, + YES_NO, +} from "../lib.ts"; + +const env = (k: string) => { + try { + return Deno.env.get(k); + } catch { + return undefined; + } +}; +const locale = () => env("LC_ALL") || env("LC_MESSAGES") || env("LANG"); + +const again = (title = "Again") => action(title, () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }); + +/** Enter draws again, then copy and paste the result. */ +const resultActions = (value: string, more: Action[] = []): Action[] => [ + again(), + copy(value, "Copy Result", { section: "Copy" }), + paste(value, "Paste Result", { section: "Copy" }), + ...more, +]; + +const T = { + en: { + coin: "Coin Flip", + dice: "Dice", + number: "Random Number", + pick: "Pick", + shuffle: "Shuffle", + color: "Random Color", + yesno: "Yes or No", + ball: "Magic 8-Ball", + result: "Result", + }, + fr: { + coin: "Pile ou face", + dice: "Dés", + number: "Nombre aléatoire", + pick: "Tirage", + shuffle: "Mélange", + color: "Couleur aléatoire", + yesno: "Oui ou non", + ball: "Boule magique", + result: "Résultat", + }, +}; + +function coinItems(count: number, lang: Lang): Item[] { + const faces = Array.from({ length: count }, () => randomInt(0, 1)); + const names = COIN[lang]; + if (count === 1) { + const face = names[faces[0]]; + return [{ + id: "coin", + title: face, + subtitle: T[lang].coin, + icon: { source: Icons.coin, tint: faces[0] === 0 ? "yellow" : "orange" }, + section: T[lang].result, + detail: { + stats: [{ label: T[lang].coin, value: face, color: faces[0] === 0 ? "yellow" : "orange" }], + metadata: [meta.text("Odds", "1 in 2", { icon: Icons.coin })], + }, + actions: resultActions(face), + }]; + } + const heads = faces.filter((f) => f === 0).length; + const seq = faces.map((f) => names[f][0]).join(" "); + return [{ + id: "coins", + title: `${heads} ${names[0]} · ${count - heads} ${names[1]}`, + subtitle: `${count} × ${T[lang].coin}`, + icon: { source: Icons.coin, tint: "yellow" }, + section: T[lang].result, + detail: { + stats: [ + { label: names[0], value: heads, color: "yellow" }, + { label: names[1], value: count - heads, color: "orange" }, + ], + markdown: `\`${seq}\``, + metadata: [meta.progress(names[0], heads / count)], + }, + actions: resultActions(faces.map((f) => names[f]).join(", ")), + }]; +} + +function diceItems(intent: Extract<Intent, { kind: "dice" }>, lang: Lang): Item[] { + const r = roll(intent.dice); + const notation = diceNotation(intent.dice); + const breakdown = rollBreakdown(r); + const span = r.max - r.min; + const ratio = span ? (r.total - r.min) / span : 1; + const color: Color = ratio >= 0.8 ? "green" : ratio <= 0.2 ? "red" : "blue"; + return [{ + id: "dice", + title: String(r.total), + subtitle: intent.dice.count === 1 && !intent.dice.modifier ? notation : `${notation} · ${breakdown}`, + icon: { source: Icons.dice, tint: color }, + section: T[lang].result, + keywords: [notation], + detail: { + stats: [ + { label: notation, value: r.total, color }, + { label: "Min", value: r.min }, + { label: "Max", value: r.max }, + ], + markdown: `${breakdown}`, + metadata: [ + meta.text("Dice", `${intent.dice.count} × d${intent.dice.sides}`, { icon: Icons.dice }), + ...(intent.dice.keep + ? [meta.text("Keep", `${intent.dice.keep > 0 ? "Highest" : "Lowest"} ${Math.abs(intent.dice.keep)}`)] + : []), + ...(intent.dice.modifier + ? [meta.text("Modifier", intent.dice.modifier > 0 ? `+${intent.dice.modifier}` : `${intent.dice.modifier}`)] + : []), + meta.progress("Roll", ratio, `${r.total} of ${r.min}–${r.max}`), + ], + }, + actions: resultActions(String(r.total), [ + copy(`${notation}: ${breakdown} = ${r.total}`, "Copy Roll Details", { section: "Copy" }), + ]), + }]; +} + +function rangeItems(intent: Extract<Intent, { kind: "range" }>, lang: Lang): Item[] { + const { range } = intent; + const n = drawInRange(range); + const shown = formatNumber(n, range.decimals); + const value = range.decimals ? n.toFixed(range.decimals) : String(n); + const bounds = `${formatNumber(range.min, range.decimals)} – ${formatNumber(range.max, range.decimals)}`; + const count = Math.round((range.max - range.min) * 10 ** range.decimals) + 1; + return [{ + id: "range", + title: shown, + subtitle: bounds, + icon: { source: Icons.hash, tint: "blue" }, + section: T[lang].result, + detail: { + stats: [{ label: T[lang].number, value: shown, color: "blue" }], + metadata: [ + meta.text("Range", `${bounds} (both included)`, { icon: Icons.hash }), + meta.text("Possible values", formatNumber(count)), + ...(range.decimals + ? [meta.text("Precision", `${range.decimals} decimal${range.decimals > 1 ? "s" : ""}`)] + : []), + meta.progress("Position", range.max > range.min ? (n - range.min) / (range.max - range.min) : 1), + ], + }, + actions: resultActions(value), + }]; +} + +function pickItems(intent: Extract<Intent, { kind: "pick" }>, lang: Lang): Item[] { + if (intent.items.length < 2) return [hint(lang, "pick")]; + const chosen = shuffle(intent.items).slice(0, intent.count); + const value = chosen.join(", "); + return [{ + id: "pick", + title: value, + subtitle: `${T[lang].pick} · ${intent.count} of ${intent.items.length}`, + icon: { source: Icons.sparkle, tint: "purple" }, + section: T[lang].result, + detail: { + stats: [ + { label: "Picked", value: intent.count, color: "purple" }, + { label: "Choices", value: intent.items.length }, + ], + markdown: intent.items.map((i) => (chosen.includes(i) ? `- **${i}**` : `- ${i}`)).join("\n"), + metadata: [meta.text("Odds", `${intent.count} in ${intent.items.length} each`, { icon: Icons.sparkle })], + }, + actions: resultActions(value), + }]; +} + +function shuffleItems(intent: Extract<Intent, { kind: "shuffle" }>, lang: Lang): Item[] { + if (intent.items.length < 2) return [hint(lang, "shuffle")]; + const order = shuffle(intent.items); + return [ + { + id: "shuffle", + title: order.join(", "), + subtitle: `${T[lang].shuffle} · ${order.length} items`, + icon: { source: Icons.shuffle, tint: "teal" }, + section: T[lang].result, + detail: { + stats: [{ label: "Items", value: order.length, color: "teal" }], + markdown: order.map((x, i) => `${i + 1}. ${x}`).join("\n"), + }, + actions: resultActions(order.join(", "), [ + copy(order.join("\n"), "Copy One per Line", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + ]), + }, + ...order.map((x, i): Item => ({ + id: `shuffle:${i}`, + title: x, + icon: { source: Icons.hash, tint: "gray" }, + accessories: [{ text: `#${i + 1}` }], + section: "Order", + actions: [copy(x), again("Shuffle Again")], + })), + ]; +} + +function colorItems(lang: Lang): Item[] { + const c = randomColor(); + const h = hex(c); + const { h: hue, s, l } = hsl(c); + const rgb = `rgb(${c.r}, ${c.g}, ${c.b})`; + const hslText = `hsl(${hue}, ${s}%, ${l}%)`; + const lum = luminance(c); + return [{ + id: "color", + title: h, + subtitle: rgb, + icon: `color:${h}`, + section: T[lang].result, + keywords: [rgb, hslText], + detail: { + stats: [ + { label: "Hue", value: `${hue}°` }, + { label: "Saturation", value: `${s} %` }, + { label: "Lightness", value: `${l} %` }, + ], + metadata: [ + meta.text("HEX", h, { icon: `color:${h}` }), + meta.text("RGB", rgb), + meta.text("HSL", hslText), + meta.separator(), + meta.text("Tone", lum > 0.4 ? "Light: dark text reads best" : "Dark: light text reads best", { + icon: lum > 0.4 ? Icons.sun : Icons.moon, + }), + ], + }, + actions: [ + again("New Color"), + copy(h, `Copy ${h}`, { section: "Copy" }), + copy(rgb, `Copy ${rgb}`, { section: "Copy" }), + copy(hslText, `Copy ${hslText}`, { section: "Copy" }), + paste(h, `Paste ${h}`, { section: "Copy" }), + ], + }]; +} + +function yesNoItems(lang: Lang): Item[] { + const yes = randomInt(0, 1) === 0; + const answer = YES_NO[lang][yes ? 0 : 1]; + return [{ + id: "yesno", + title: answer, + subtitle: T[lang].yesno, + icon: { source: yes ? Icons.check : Icons.cross, tint: yes ? "green" : "red" }, + section: T[lang].result, + detail: { stats: [{ label: T[lang].yesno, value: answer, color: yes ? "green" : "red" }] }, + actions: resultActions(answer), + }]; +} + +const MOOD: Record<Mood, { color: Color; icon: string }> = { + yes: { color: "green", icon: Icons.check }, + maybe: { color: "yellow", icon: Icons.question }, + no: { color: "red", icon: Icons.cross }, +}; + +const MOOD_NAME: Record<Lang, Record<Mood, string>> = { + en: { yes: "Positive", maybe: "Non-committal", no: "Negative" }, + fr: { yes: "Positive", maybe: "Incertaine", no: "Négative" }, +}; + +function eightBallItems(question: string, lang: Lang): Item[] { + const [answer, mood] = pick(EIGHT_BALL[lang]); + return [{ + id: "8ball", + title: answer, + subtitle: question || T[lang].ball, + icon: { source: Icons.sparkle, tint: MOOD[mood].color }, + section: T[lang].ball, + detail: { + markdown: question ? `> ${question}\n\n### ${answer}` : `### ${answer}`, + metadata: [ + meta.text( + "Mood", + MOOD_NAME[lang][mood], + { icon: MOOD[mood].icon, color: MOOD[mood].color }, + ), + ], + }, + actions: resultActions(answer, [ + ...(question ? [copy(`${question} ${answer}`, "Copy Question and Answer", { section: "Copy" })] : []), + ]), + }]; +} + +const EXAMPLES = { + pick: ["pick pizza, sushi, tacos", "pick 2 Alice, Bob, Carol, Dan"], + shuffle: ["shuffle Alice, Bob, Carol"], +}; + +function hint(lang: Lang, kind: "pick" | "shuffle"): Item { + return { + id: `hint:${kind}`, + title: kind === "pick" ? "List at least two choices" : "List at least two items", + subtitle: EXAMPLES[kind][0], + icon: { source: Icons.info, tint: "gray" }, + section: T[lang].result, + detail: { + markdown: `Separate them with commas, semicolons, \`or\` or new lines.\n\n${ + EXAMPLES[kind].map((e) => `- \`${e}\``).join("\n") + }`, + }, + }; +} + +/** The tools, with a fresh draw each, and what to type for the others. */ +function tools(lang: Lang): Item[] { + const quick = [ + ...coinItems(1, lang), + ...diceItems({ kind: "dice", dice: { count: 1, sides: 6, modifier: 0 } }, lang), + ...rangeItems({ kind: "range", range: { min: 1, max: 100, decimals: 0 } }, lang), + ...yesNoItems(lang), + ...colorItems(lang), + ...eightBallItems("", lang), + ].map((i) => ({ ...i, section: "Quick Draws" })); + quick[0].subtitle = T[lang].coin; + quick[1].subtitle = `${T[lang].dice} · d6`; + quick[2].subtitle = `${T[lang].number} · 1–100`; + quick[3].subtitle = T[lang].yesno; + quick[4].subtitle = `${T[lang].color} · ${quick[4].subtitle}`; + quick[5].subtitle = T[lang].ball; + const typed: { title: string; examples: string[]; icon: string; tint: Color; about: string }[] = [ + { + title: "Dice notation", + examples: ["2d6+3", "d20", "4d6kh3"], + icon: Icons.dice, + tint: "blue", + about: "Up to 100 dice of 2 to 1000 sides. `kh3` keeps the 3 highest dice, `kl1` the lowest.", + }, + { + title: "Number in a range", + examples: ["1-100", "0.5-2.5", "1 to 6", "de 1 à 10"], + icon: Icons.hash, + tint: "blue", + about: "Both bounds are included. Decimals in a bound set the precision.", + }, + { + title: "Pick from a list", + examples: ["pick tea, coffee, juice", "pick 2 Alice, Bob, Carol", "pizza or sushi"], + icon: Icons.sparkle, + tint: "purple", + about: "Or just type a list. `pick 2 …` draws two different ones.", + }, + { + title: "Shuffle a list", + examples: ["shuffle Alice, Bob, Carol", "mélanger a; b; c"], + icon: Icons.shuffle, + tint: "teal", + about: "The items in a random order.", + }, + { + title: "Several coins", + examples: ["flip 10", "pile ou face"], + icon: Icons.coin, + tint: "yellow", + about: "Counts heads and tails, up to 100 coins.", + }, + { + title: "Ask the Magic 8-Ball", + examples: ["8ball Will it rain tomorrow?", "boule Est-ce que je vais gagner ?"], + icon: Icons.question, + tint: "pink", + about: "Any question ending with `?` gets an answer, in English or French.", + }, + ]; + return [ + ...quick, + ...typed.map((t): Item => ({ + id: `type:${t.title}`, + title: t.title, + subtitle: t.examples.join(" · "), + icon: { source: t.icon, tint: t.tint }, + section: "Type", + keywords: t.examples, + detail: { markdown: `${t.about}\n\n${t.examples.map((e) => `- \`${e}\``).join("\n")}` }, + actions: t.examples.map((e) => copy(e, `Copy “${e}”`, { icon: Icons.copy })), + })), + ]; +} + +export default (ctx: Context) => { + const prefs = ctx.preferences as { language?: string }; + const langFor = (query: string): Lang => + (prefs.language ?? "auto").trim().toLowerCase() === "auto" && looksFrench(query) + ? "fr" + : language(prefs.language, locale()); + return list({ + placeholder: "Coin, 2d6+3, 1-100, pick a, b, c, shuffle…, color, 8ball…", + status: (items) => items.find((i) => i.section !== "Order")?.subtitle ?? "Random", + empty: { title: "Nothing to draw", icon: Icons.dice }, + search(query) { + const lang = langFor(query); + const intent = parseIntent(query); + switch (intent.kind) { + case "tools": + return tools(lang); + case "coin": + return coinItems(intent.count, lang); + case "dice": + return diceItems(intent, lang); + case "range": + return rangeItems(intent, lang); + case "pick": + return pickItems(intent, lang); + case "shuffle": + return shuffleItems(intent, lang); + case "color": + return colorItems(lang); + case "yesno": + return yesNoItems(lang); + case "eightball": + return eightBallItems(intent.question, lang); + case "unknown": + return tools(lang).filter((i) => + `${i.title} ${i.subtitle ?? ""}`.toLowerCase().includes(intent.text.toLowerCase()) + ); + } + }, + }); +}; diff --git a/extensions/random/commands/roll-dice.ts b/extensions/random/commands/roll-dice.ts new file mode 100644 index 0000000..092cfbb --- /dev/null +++ b/extensions/random/commands/roll-dice.ts @@ -0,0 +1,14 @@ +import { clipboard, run } from "@backslash/api"; +import { diceNotation, parseDice, roll, rollBreakdown } from "../lib.ts"; + +// "Roll Dice 2d6+3": flashes the total with the dice, and copies the total. +export default run<{ dice?: string }>(async ({ args }) => { + const text = String(args.dice ?? "").trim() || "1d6"; + const dice = parseDice(text); + if (!dice) throw new Error(`“${text}” isn't dice notation: try 2d6+3, d20 or 4d6kh3`); + const r = roll(dice); + await clipboard.write(String(r.total)); + return dice.count === 1 && !dice.modifier + ? `${diceNotation(dice)}: ${r.total}` + : `${diceNotation(dice)}: ${r.total} ${rollBreakdown(r)}`; +}); diff --git a/extensions/random/lib.ts b/extensions/random/lib.ts new file mode 100644 index 0000000..31ae689 --- /dev/null +++ b/extensions/random/lib.ts @@ -0,0 +1,373 @@ +// Parsing what the user typed into a random draw, and drawing it. Pure: the +// source of randomness is passed in (crypto by default), tested in lib_test.ts. + +/** Returns a uniformly random unsigned 32-bit integer. */ +export type Source = () => number; + +const buffer = new Uint32Array(64); +let left = 0; + +/** crypto.getRandomValues, 64 values at a time. */ +export const cryptoSource: Source = () => { + if (left === 0) { + crypto.getRandomValues(buffer); + left = buffer.length; + } + return buffer[--left]; +}; + +const TWO_32 = 2 ** 32; +const TWO_53 = 2 ** 53; + +/** + * A uniform integer in [min, max], both included, without modulo bias + * (rejection sampling on 32 or 53 random bits). + */ +export function randomInt(min: number, max: number, rnd: Source = cryptoSource): number { + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) throw new RangeError("Bounds must be safe integers"); + if (max < min) [min, max] = [max, min]; + const span = max - min + 1; + if (span > TWO_53) throw new RangeError("Range too large"); + if (span === 1) return min; + if (span <= TWO_32) { + const limit = TWO_32 - (TWO_32 % span); + for (;;) { + const x = rnd(); + if (x < limit) return min + (x % span); + } + } + const limit = TWO_53 - (TWO_53 % span); + for (;;) { + const x = (rnd() & 0x1fffff) * TWO_32 + rnd(); + if (x < limit) return min + (x % span); + } +} + +/** A uniform float in [0, 1) with 53 bits of precision. */ +export const randomFloat = (rnd: Source = cryptoSource) => ((rnd() & 0x1fffff) * TWO_32 + rnd()) / TWO_53; + +/** Fisher–Yates, on a copy. */ +export function shuffle<T>(items: readonly T[], rnd: Source = cryptoSource): T[] { + const out = [...items]; + for (let i = out.length - 1; i > 0; i--) { + const j = randomInt(0, i, rnd); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +export const pick = <T>(items: readonly T[], rnd: Source = cryptoSource): T => + items[randomInt(0, items.length - 1, rnd)]; + +// --------------------------------------------------------------------------- +// Language + +export type Lang = "en" | "fr"; + +/** The preference ("auto", "en", "fr"), falling back on the locale variables. */ +export function language(pref: string | undefined, locale: string | undefined): Lang { + const p = (pref ?? "").trim().toLowerCase(); + if (p === "en" || p === "fr") return p; + return (locale ?? "").toLowerCase().startsWith("fr") ? "fr" : "en"; +} + +// --------------------------------------------------------------------------- +// Dice + +export interface Dice { + count: number; + sides: number; + modifier: number; + /** Keep the highest (positive) or lowest (negative) N dice, as in "4d6kh3". */ + keep?: number; +} + +export const MAX_DICE = 100; +export const MAX_SIDES = 1000; + +/** "2d6+3", "d20", "3D8-1", "4d6kh3", "2de6" (French). Undefined when it isn't dice notation. */ +export function parseDice(text: string): Dice | undefined { + const m = text.trim().toLowerCase().replace(/\s+/g, "").match( + /^(\d{0,3})(?:d|de|dé)(\d{1,4})(?:(k|kh|kl)(\d{1,3}))?(?:([+-])(\d{1,5}))?$/, + ); + if (!m) return undefined; + const count = m[1] ? Number(m[1]) : 1; + const sides = Number(m[2]); + if (count < 1 || count > MAX_DICE || sides < 2 || sides > MAX_SIDES) return undefined; + const modifier = m[5] ? (m[5] === "-" ? -1 : 1) * Number(m[6]) : 0; + let keep: number | undefined; + if (m[3]) { + const n = Number(m[4]); + if (n < 1 || n > count) return undefined; + keep = m[3] === "kl" ? -n : n; + } + return { count, sides, modifier, ...(keep !== undefined && keep !== count ? { keep } : {}) }; +} + +export const diceNotation = (d: Dice) => + `${d.count}d${d.sides}${d.keep ? `${d.keep > 0 ? "kh" : "kl"}${Math.abs(d.keep)}` : ""}${ + d.modifier > 0 ? `+${d.modifier}` : d.modifier < 0 ? `${d.modifier}` : "" + }`; + +export interface Roll { + dice: Dice; + rolls: number[]; + /** Indexes of the dice that count (all of them unless `keep`). */ + kept: number[]; + total: number; + min: number; + max: number; +} + +export function roll(dice: Dice, rnd: Source = cryptoSource): Roll { + const rolls = Array.from({ length: dice.count }, () => randomInt(1, dice.sides, rnd)); + let kept = rolls.map((_, i) => i); + if (dice.keep) { + const order = [...kept].sort((a, b) => dice.keep! > 0 ? rolls[b] - rolls[a] : rolls[a] - rolls[b]); + kept = order.slice(0, Math.abs(dice.keep)).sort((a, b) => a - b); + } + const n = kept.length; + return { + dice, + rolls, + kept, + total: kept.reduce((s, i) => s + rolls[i], 0) + dice.modifier, + min: n + dice.modifier, + max: n * dice.sides + dice.modifier, + }; +} + +/** "[3, 5] + 3", with the dropped dice in parentheses: "[(1), 4, 6, 5]". */ +export function rollBreakdown(r: Roll): string { + const kept = new Set(r.kept); + const dice = r.rolls.map((v, i) => kept.has(i) ? String(v) : `(${v})`).join(", "); + const mod = r.dice.modifier > 0 ? ` + ${r.dice.modifier}` : r.dice.modifier < 0 ? ` − ${-r.dice.modifier}` : ""; + return `[${dice}]${mod}`; +} + +// --------------------------------------------------------------------------- +// Ranges + +export interface Range { + min: number; + max: number; + /** Digits after the decimal point; 0 for integers. */ + decimals: number; +} + +const NUM = String.raw`[-−]?\d+(?:[.,]\d+)?`; +const num = (s: string) => Number(s.replace("−", "-").replace(",", ".")); +const decimalsOf = (s: string) => s.match(/[.,](\d+)$/)?.[1].length ?? 0; + +/** + * "1-100", "1..6", "-10 10", "1 to 50", "de 1 à 10", "between 5 and 8", + * "0.5-2.5", or one number "100" (= 1 to 100). Undefined otherwise. + */ +export function parseRange(text: string): Range | undefined { + const t = text.trim().toLowerCase() + .replace(/^(?:random|rand|number|nombre|nb|num|entre|between|from|de|du)\s+/, "") + .replace(/^(?:random|rand|number|nombre|nb|num|entre|between|from|de|du)\s+/, ""); + const single = t.match(new RegExp(`^(${NUM})$`)); + if (single) { + const max = num(single[1]); + if (!Number.isFinite(max) || max < 1 || decimalsOf(single[1])) return undefined; + return safe({ min: 1, max, decimals: 0 }); + } + const m = t.match( + new RegExp(`^(${NUM})\\s*(?:-|–|—|\\.\\.\\.?|:|\\s+(?:to|and|et|à|a|au)\\s+|\\s+)\\s*(${NUM})$`), + ); + if (!m) return undefined; + let min = num(m[1]); + let max = num(m[2]); + if (!Number.isFinite(min) || !Number.isFinite(max)) return undefined; + if (max < min) [min, max] = [max, min]; + return safe({ min, max, decimals: Math.min(6, Math.max(decimalsOf(m[1]), decimalsOf(m[2]))) }); +} + +const safe = (r: Range): Range | undefined => + Number.isSafeInteger(Math.round(r.min * 10 ** r.decimals)) && + Number.isSafeInteger(Math.round(r.max * 10 ** r.decimals)) + ? r + : undefined; + +/** A number in the range, both bounds included, in steps of 10^-decimals. */ +export function drawInRange(r: Range, rnd: Source = cryptoSource): number { + const f = 10 ** r.decimals; + const n = randomInt(Math.round(r.min * f), Math.round(r.max * f), rnd); + return r.decimals ? Number((n / f).toFixed(r.decimals)) : n; +} + +export const formatNumber = (n: number, decimals = 0) => + n.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }).replace(/,/g, " "); + +// --------------------------------------------------------------------------- +// Lists + +/** "a, b, c", "a; b", "a or b", "tea ou café", one per line. */ +export function parseList(text: string): string[] { + const t = text.trim(); + const parts = /[\n,;|]/.test(t) ? t.split(/[\n,;|]/) : t.split(/\s+(?:or|ou)\s+/i); + return parts.map((p) => p.trim()).filter(Boolean); +} + +// --------------------------------------------------------------------------- +// Colors + +export interface RGB { + r: number; + g: number; + b: number; +} + +export const randomColor = (rnd: Source = cryptoSource): RGB => { + const x = rnd(); + return { r: x & 0xff, g: (x >>> 8) & 0xff, b: (x >>> 16) & 0xff }; +}; + +export const hex = (c: RGB) => `#${[c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; + +export function hsl(c: RGB): { h: number; s: number; l: number } { + const r = c.r / 255, g = c.g / 255, b = c.b / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) }; + const s = d / (1 - Math.abs(2 * l - 1)); + const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + return { h: Math.round(((h * 60) + 360) % 360), s: Math.round(s * 100), l: Math.round(l * 100) }; +} + +/** Relative luminance (WCAG), to tell light colors from dark ones. */ +export function luminance(c: RGB): number { + const lin = (v: number) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b); +} + +// --------------------------------------------------------------------------- +// Answers + +export const COIN: Record<Lang, [string, string]> = { en: ["Heads", "Tails"], fr: ["Pile", "Face"] }; +export const YES_NO: Record<Lang, [string, string]> = { en: ["Yes", "No"], fr: ["Oui", "Non"] }; + +export type Mood = "yes" | "maybe" | "no"; + +/** The 20 classic Magic 8-Ball answers: 10 positive, 5 non-committal, 5 negative. */ +export const EIGHT_BALL: Record<Lang, [string, Mood][]> = { + en: [ + ["It is certain.", "yes"], + ["It is decidedly so.", "yes"], + ["Without a doubt.", "yes"], + ["Yes, definitely.", "yes"], + ["You may rely on it.", "yes"], + ["As I see it, yes.", "yes"], + ["Most likely.", "yes"], + ["Outlook good.", "yes"], + ["Yes.", "yes"], + ["Signs point to yes.", "yes"], + ["Reply hazy, try again.", "maybe"], + ["Ask again later.", "maybe"], + ["Better not tell you now.", "maybe"], + ["Cannot predict now.", "maybe"], + ["Concentrate and ask again.", "maybe"], + ["Don't count on it.", "no"], + ["My reply is no.", "no"], + ["My sources say no.", "no"], + ["Outlook not so good.", "no"], + ["Very doubtful.", "no"], + ], + fr: [ + ["C'est certain.", "yes"], + ["C'est décidément le cas.", "yes"], + ["Sans aucun doute.", "yes"], + ["Oui, absolument.", "yes"], + ["Tu peux compter dessus.", "yes"], + ["Tel que je le vois, oui.", "yes"], + ["Très probablement.", "yes"], + ["Les perspectives sont bonnes.", "yes"], + ["Oui.", "yes"], + ["Les signes disent oui.", "yes"], + ["Réponse floue, réessaie.", "maybe"], + ["Redemande plus tard.", "maybe"], + ["Mieux vaut ne pas te le dire maintenant.", "maybe"], + ["Impossible de prédire maintenant.", "maybe"], + ["Concentre-toi et redemande.", "maybe"], + ["N'y compte pas.", "no"], + ["Ma réponse est non.", "no"], + ["Mes sources disent non.", "no"], + ["Les perspectives ne sont pas bonnes.", "no"], + ["Très peu probable.", "no"], + ], +}; + +// --------------------------------------------------------------------------- +// Intent + +export type Intent = + | { kind: "tools" } + | { kind: "coin"; count: number } + | { kind: "dice"; dice: Dice } + | { kind: "range"; range: Range } + | { kind: "pick"; items: string[]; count: number } + | { kind: "shuffle"; items: string[] } + | { kind: "color" } + | { kind: "yesno" } + | { kind: "eightball"; question: string } + | { kind: "unknown"; text: string }; + +const strip = (t: string, re: RegExp) => t.replace(re, "").trim(); + +/** What the text typed in the search bar asks for. Also tells whether it reads as French. */ +export function parseIntent(query: string): Intent { + const t = query.trim(); + const lower = t.toLowerCase(); + if (!t) return { kind: "tools" }; + + const coin = lower.match(/^(?:flip|toss|coins?|coinflip|heads or tails|pile ou face|pile|pièce|piece)(?=\s|$)(.*)$/); + if (coin && /^(?:\s+(?:a|an|une|une?|\d{1,3}|x\d{1,3}|[x×]|coins?|pièces?|times|fois))*$/.test(coin[1])) { + const n = coin[1].match(/\d{1,3}/)?.[0]; + return { kind: "coin", count: Math.max(1, Math.min(100, Number(n ?? 1))) }; + } + + if (/^(?:dice|die|roll|dé|dés|de|lancer)$/.test(lower)) { + return { kind: "dice", dice: { count: 1, sides: 6, modifier: 0 } }; + } + const diceText = strip(lower, /^(?:roll|dice|lancer|dés?)\s+/); + const dice = parseDice(diceText); + if (dice) return { kind: "dice", dice }; + + if (/^(?:colou?r|couleur|hex|swatch)(?:\s+(?:random|aléatoire))?$/.test(lower)) return { kind: "color" }; + if (/^(?:random\s+colou?r|couleur\s+aléatoire)$/.test(lower)) return { kind: "color" }; + + if (/^(?:yes\s*(?:or|\/)\s*no|y\/?n|oui\s*(?:ou|\/)\s*non|yesno)\s*\??$/.test(lower)) return { kind: "yesno" }; + + const ball = lower.match(/^(?:8\s*-?ball|8|magic\s*8(?:\s*-?ball)?|boule\s*magique|boule)\b[\s:,]*/); + if (ball) return { kind: "eightball", question: t.slice(ball[0].length).trim() }; + + const shuffleWord = lower.match(/^(?:shuffle|mix|mélanger|melanger|mélange|melange|order|ordre)\b[\s:]*/); + if (shuffleWord) return { kind: "shuffle", items: parseList(t.slice(shuffleWord[0].length)) }; + + const pickWord = lower.match( + /^(?:pick|choose|choice|select|draw|choisir|choisis|choix|tirer|tire)(?:\s+(\d{1,3}))?(?:\s+(?:from|among|parmi|dans))?\b[\s:]*/, + ); + if (pickWord) { + const items = parseList(t.slice(pickWord[0].length)); + return { kind: "pick", items, count: Math.max(1, Math.min(items.length || 1, Number(pickWord[1] ?? 1))) }; + } + + const range = parseRange(t); + if (range) return { kind: "range", range }; + + if (t.endsWith("?")) return { kind: "eightball", question: t }; + + const items = parseList(t); + if (items.length >= 2) return { kind: "pick", items, count: 1 }; + return { kind: "unknown", text: t }; +} + +/** Whether the text reads as French, for the answers when the preference is "auto". */ +export const looksFrench = (query: string) => + /\b(pile|pièce|dé|dés|couleur|oui|non|boule|choisir|choisis|mélanger|parmi|est-ce|est ce|je|tu|vais|dois|ou)\b/i + .test(query); diff --git a/extensions/random/lib_test.ts b/extensions/random/lib_test.ts new file mode 100644 index 0000000..21b0347 --- /dev/null +++ b/extensions/random/lib_test.ts @@ -0,0 +1,172 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + diceNotation, + drawInRange, + EIGHT_BALL, + hex, + hsl, + language, + looksFrench, + parseDice, + parseIntent, + parseList, + parseRange, + randomColor, + randomFloat, + randomInt, + roll, + rollBreakdown, + shuffle, + type Source, +} from "./lib.ts"; + +/** Returns the given values in a loop. */ +const seq = (...values: number[]): Source => { + let i = 0; + return () => values[i++ % values.length]; +}; + +Deno.test("dice notation", () => { + assertEquals(parseDice("2d6+3"), { count: 2, sides: 6, modifier: 3 }); + assertEquals(parseDice("d20"), { count: 1, sides: 20, modifier: 0 }); + assertEquals(parseDice("3D8 - 1"), { count: 3, sides: 8, modifier: -1 }); + assertEquals(parseDice("4d6kh3"), { count: 4, sides: 6, modifier: 0, keep: 3 }); + assertEquals(parseDice("2d20kl1+5"), { count: 2, sides: 20, modifier: 5, keep: -1 }); + assertEquals(parseDice("2de6"), { count: 2, sides: 6, modifier: 0 }); + assertEquals(parseDice("2d6k2"), { count: 2, sides: 6, modifier: 0 }, "keeping every die is no keep"); + for (const bad of ["d1", "0d6", "101d6", "2d1001", "2d6kh3", "hello", "2d", "d"]) { + assertEquals(parseDice(bad), undefined, bad); + } + assertEquals(diceNotation({ count: 4, sides: 6, modifier: -2, keep: 3 }), "4d6kh3-2"); +}); + +Deno.test("dice rolls stay within bounds and keep the right dice", () => { + const d = parseDice("3d6+2")!; + for (let i = 0; i < 2000; i++) { + const r = roll(d); + assert(r.rolls.every((v) => v >= 1 && v <= 6)); + assert(r.total >= 5 && r.total <= 20); + assertEquals([r.min, r.max], [5, 20]); + } + // seq values % 6 + 1: 3 → 4, 0 → 1, 5 → 6, 2 → 3 + const r = roll(parseDice("4d6kh3")!, seq(3, 0, 5, 2)); + assertEquals(r.rolls, [4, 1, 6, 3]); + assertEquals(r.kept, [0, 2, 3]); + assertEquals(r.total, 13); + assertEquals(rollBreakdown(r), "[4, (1), 6, 3]"); + const low = roll(parseDice("2d20kl1-1")!, seq(19, 4)); + assertEquals([low.rolls, low.total, rollBreakdown(low)], [[20, 5], 4, "[(20), 5] − 1"]); +}); + +Deno.test("ranges", () => { + assertEquals(parseRange("1-100"), { min: 1, max: 100, decimals: 0 }); + assertEquals(parseRange("1..6"), { min: 1, max: 6, decimals: 0 }); + assertEquals(parseRange("-10 10"), { min: -10, max: 10, decimals: 0 }); + assertEquals(parseRange("-20--10"), { min: -20, max: -10, decimals: 0 }); + assertEquals(parseRange("50 to 1"), { min: 1, max: 50, decimals: 0 }); + assertEquals(parseRange("de 1 à 10"), { min: 1, max: 10, decimals: 0 }); + assertEquals(parseRange("between 5 and 8"), { min: 5, max: 8, decimals: 0 }); + assertEquals(parseRange("0.5-2.25"), { min: 0.5, max: 2.25, decimals: 2 }); + assertEquals(parseRange("0,5 - 1,5"), { min: 0.5, max: 1.5, decimals: 1 }); + assertEquals(parseRange("100"), { min: 1, max: 100, decimals: 0 }); + for (const bad of ["abc", "1-", "0", "1-2-3", "1e400-2"]) assertEquals(parseRange(bad), undefined, bad); +}); + +Deno.test("random integers cover the range, without bias, within bounds", () => { + const counts = new Map<number, number>(); + const n = 60000; + for (let i = 0; i < n; i++) { + const v = randomInt(-2, 3); + assert(v >= -2 && v <= 3); + counts.set(v, (counts.get(v) ?? 0) + 1); + } + assertEquals([...counts.keys()].sort((a, b) => a - b), [-2, -1, 0, 1, 2, 3]); + // Each value expects 10000; 5 % is about 10 standard deviations. + for (const c of counts.values()) assert(Math.abs(c - n / 6) < n / 6 * 0.05, `${c}`); + assertEquals(randomInt(7, 7), 7); + assertEquals(randomInt(10, 1, seq(0)), 1, "reversed bounds"); + // Values past the last full multiple of the span are rejected: 2^32 % 3 = 1. + assertEquals(randomInt(0, 2, seq(2 ** 32 - 1, 4)), 1); + const big = randomInt(0, 2 ** 40); + assert(big >= 0 && big <= 2 ** 40 && Number.isInteger(big)); + assertThrows(() => randomInt(0, 2 ** 60)); + assertThrows(() => randomInt(0.5, 2)); + for (let i = 0; i < 1000; i++) { + const f = randomFloat(); + assert(f >= 0 && f < 1); + } +}); + +Deno.test("decimal ranges draw on the grid, both bounds reachable", () => { + const r = parseRange("0.5-0.7")!; + const seen = new Set<number>(); + for (let i = 0; i < 500; i++) seen.add(drawInRange(r)); + assertEquals([...seen].sort(), [0.5, 0.6, 0.7]); + assertEquals(drawInRange({ min: 1, max: 100, decimals: 0 }, seq(41)), 42); +}); + +Deno.test("lists, shuffle and pick", () => { + assertEquals(parseList("pizza, sushi ,tacos,"), ["pizza", "sushi", "tacos"]); + assertEquals(parseList("a; b | c"), ["a", "b", "c"]); + assertEquals(parseList("tea or coffee"), ["tea", "coffee"]); + assertEquals(parseList("thé ou café"), ["thé", "café"]); + assertEquals(parseList("one\ntwo"), ["one", "two"]); + const items = ["a", "b", "c", "d", "e"]; + const firsts = new Set<string>(); + for (let i = 0; i < 500; i++) { + const s = shuffle(items); + assertEquals([...s].sort(), items); + firsts.add(s[0]); + } + assertEquals(firsts.size, 5); + assertEquals(items, ["a", "b", "c", "d", "e"], "the input is left alone"); +}); + +Deno.test("intents", () => { + assertEquals(parseIntent(""), { kind: "tools" }); + assertEquals(parseIntent("flip"), { kind: "coin", count: 1 }); + assertEquals(parseIntent("flip a coin"), { kind: "coin", count: 1 }); + assertEquals(parseIntent("flip 10"), { kind: "coin", count: 10 }); + assertEquals(parseIntent("pile ou face"), { kind: "coin", count: 1 }); + assertEquals(parseIntent("roll 2d6+3"), { kind: "dice", dice: { count: 2, sides: 6, modifier: 3 } }); + assertEquals(parseIntent("d20"), { kind: "dice", dice: { count: 1, sides: 20, modifier: 0 } }); + assertEquals(parseIntent("dice"), { kind: "dice", dice: { count: 1, sides: 6, modifier: 0 } }); + assertEquals(parseIntent("1-100"), { kind: "range", range: { min: 1, max: 100, decimals: 0 } }); + assertEquals(parseIntent("pick a, b, c"), { kind: "pick", items: ["a", "b", "c"], count: 1 }); + assertEquals(parseIntent("pick 2 from a, b, c"), { kind: "pick", items: ["a", "b", "c"], count: 2 }); + assertEquals(parseIntent("choisir parmi thé, café"), { kind: "pick", items: ["thé", "café"], count: 1 }); + assertEquals(parseIntent("pizza or sushi"), { kind: "pick", items: ["pizza", "sushi"], count: 1 }); + assertEquals(parseIntent("shuffle Alice, Bob"), { kind: "shuffle", items: ["Alice", "Bob"] }); + assertEquals(parseIntent("mélanger a; b; c"), { kind: "shuffle", items: ["a", "b", "c"] }); + assertEquals(parseIntent("color"), { kind: "color" }); + assertEquals(parseIntent("couleur"), { kind: "color" }); + assertEquals(parseIntent("yes or no"), { kind: "yesno" }); + assertEquals(parseIntent("oui ou non ?"), { kind: "yesno" }); + assertEquals(parseIntent("8ball Will it rain?"), { kind: "eightball", question: "Will it rain?" }); + assertEquals(parseIntent("Should I go?"), { kind: "eightball", question: "Should I go?" }); + assertEquals(parseIntent("boule magique"), { kind: "eightball", question: "" }); + assertEquals(parseIntent("coffee"), { kind: "unknown", text: "coffee" }); +}); + +Deno.test("colors", () => { + const c = randomColor(seq(0x00336699)); + assertEquals(c, { r: 0x99, g: 0x66, b: 0x33 }); + assertEquals(hex(c), "#996633"); + assertEquals(hsl({ r: 255, g: 0, b: 0 }), { h: 0, s: 100, l: 50 }); + assertEquals(hsl({ r: 0, g: 0, b: 255 }), { h: 240, s: 100, l: 50 }); + assertEquals(hsl({ r: 128, g: 128, b: 128 }), { h: 0, s: 0, l: 50 }); +}); + +Deno.test("languages and answers", () => { + assertEquals(language("fr", "en_US.UTF-8"), "fr"); + assertEquals(language("auto", "fr_FR.UTF-8"), "fr"); + assertEquals(language(undefined, "de_DE.UTF-8"), "en"); + assert(looksFrench("Est-ce que je vais gagner ?")); + assert(!looksFrench("Will you win?")); + for (const lang of ["en", "fr"] as const) { + const answers = EIGHT_BALL[lang]; + assertEquals(answers.length, 20); + assertEquals(answers.filter(([, m]) => m === "yes").length, 10); + assertEquals(answers.filter(([, m]) => m === "maybe").length, 5); + } +}); diff --git a/extensions/reddit/api.ts b/extensions/reddit/api.ts new file mode 100644 index 0000000..8c146e2 --- /dev/null +++ b/extensions/reddit/api.ts @@ -0,0 +1,135 @@ +// Reddit's public JSON endpoints (no account). Reddit refuses unauthenticated +// requests from some networks (403) and rate limits them (429): the last +// listing is kept to be shown when that happens. + +import { storage } from "@backslash/api"; +import { + type Comment, + commentsOf, + type Listing, + listingPath, + parseSubreddits, + type Post, + postsOf, + type RedditComment, + type RedditPost, + refusal, + type Sort, +} from "./lib.ts"; + +const BASE = "https://www.reddit.com"; +// Reddit asks for a descriptive User-Agent: <platform>:<app id>:<version> (by /u/<user>). +const HEADERS = { "User-Agent": "linux:app.backslash.reddit:1.0.0 (by /u/backslash-app)", Accept: "application/json" }; + +type Json = Parameters<typeof storage.set>[1]; + +/** Reddit refused: `blocked` when it's about this network or the rate, not the subreddit. */ +export class Refused extends Error { + constructor(message: string, readonly blocked: boolean) { + super(message); + } +} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { + const timeout = AbortSignal.timeout(15_000); + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + headers: HEADERS, + redirect: "manual", + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + } catch (e) { + if (signal?.aborted) throw e; + throw new Refused( + timeout.aborted ? "Reddit is taking too long to answer" : `Reddit is unreachable: ${(e as Error).message}`, + true, + ); + } + // A subreddit that doesn't exist redirects to the search page. + if (res.status >= 300 && res.status < 400) { + await res.body?.cancel(); + throw new Refused("This subreddit doesn't exist", false); + } + const text = await res.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = undefined; + } + if (!res.ok) { + const r = refusal(res.status, body, res.headers.get("retry-after")); + throw new Refused(r.message, r.blocked); + } + if (body === undefined) throw new Refused("Reddit answered with a web page instead of data (blocked?)", true); + return body as T; +} + +export interface Loaded { + posts: Post[]; + /** When the posts are a saved copy because Reddit refused. */ + savedAt?: number; + refused?: Refused; +} + +/** A listing; when Reddit blocks or rate limits, the last copy if there is one. */ +export async function listing(subs: string[], sort: Sort, nsfw: boolean, signal?: AbortSignal): Promise<Loaded> { + const path = listingPath(subs, sort); + const key = `listing:${path}`; + try { + const l = await get<Listing<RedditPost>>(path, signal); + const posts = postsOf(l, nsfw); + await save(key, { at: Date.now(), posts }); + return { posts }; + } catch (e) { + if (!(e instanceof Refused) || !e.blocked) throw e; + const saved = await load<{ at: number; posts: Post[] }>(key); + if (!saved) throw e; + return { posts: saved.posts.filter((p) => nsfw || !p.nsfw), savedAt: saved.at, refused: e }; + } +} + +export async function comments(id: string, signal?: AbortSignal): Promise<Comment[]> { + const answer = await get<[unknown, Listing<RedditComment>]>( + `/comments/${encodeURIComponent(id)}.json?limit=12&depth=1&sort=top&raw_json=1`, + signal, + ); + return commentsOf(answer); +} + +// --------------------------------------------------------------------------- +// Subreddits: the preference's, then those added from the launcher. + +export async function added(): Promise<string[]> { + return await load<string[]>("subreddits") ?? []; +} + +export async function subreddits( + preferences: Record<string, unknown>, +): Promise<{ name: string; fromPrefs: boolean }[]> { + const prefs = parseSubreddits(preferences.subreddits); + const mine = parseSubreddits((await added()).join(" ")); + const seen = new Set(prefs.map((s) => s.toLowerCase())); + return [ + ...prefs.map((name) => ({ name, fromPrefs: true })), + ...mine.filter((s) => !seen.has(s.toLowerCase())).map((name) => ({ name, fromPrefs: false })), + ]; +} + +export async function addSubreddit(name: string): Promise<void> { + const list = await added(); + if (!list.some((s) => s.toLowerCase() === name.toLowerCase())) await save("subreddits", [...list, name]); +} + +export async function removeSubreddit(name: string): Promise<void> { + await save("subreddits", (await added()).filter((s) => s.toLowerCase() !== name.toLowerCase())); +} diff --git a/extensions/reddit/backslash.toml b/extensions/reddit/backslash.toml new file mode 100644 index 0000000..99c8fce --- /dev/null +++ b/extensions/reddit/backslash.toml @@ -0,0 +1,41 @@ +[extension] +id = "reddit" +title = "Reddit" +description = "Hot, new and top posts from your subreddits with scores, comments, flairs, pictures and the top comments, without an account" +version = "1.0.0" +author = "Backslash" +icon = "bs-comment" +license = "MIT" + +# Reddit's public JSON endpoints on www.reddit.com, and the hosts of post +# pictures: i.redd.it (uploads), preview.redd.it and external-preview.redd.it +# (previews of uploads and links). +[permissions] +net = ["www.reddit.com", "i.redd.it", "preview.redd.it", "external-preview.redd.it"] + +[[commands]] +name = "reddit" +title = "Reddit" +description = "Posts from your subreddits: Hot, New or Top today" +icon = "bs-comment" +keywords = ["reddit", "subreddit", "posts", "news", "front page", "forum", "actualités", "fil", "communauté"] + +[[commands]] +name = "subreddits" +title = "Reddit Subreddits" +description = "Browse one of your subreddits, add or remove subreddits" +icon = "bs-hash" +keywords = ["reddit", "subreddit", "subreddits", "communities", "add subreddit", "communautés", "abonnements"] + +[[preferences]] +name = "subreddits" +title = "Subreddits" +description = "Subreddits to follow, separated by commas or spaces (more can be added from the launcher)" +default = "linux, programming, technology" + +[[preferences]] +name = "nsfw" +title = "Show NSFW posts" +description = "Posts marked NSFW are hidden unless this is on" +type = "boolean" +default = false diff --git a/extensions/reddit/commands/reddit.ts b/extensions/reddit/commands/reddit.ts new file mode 100644 index 0000000..a140e5d --- /dev/null +++ b/extensions/reddit/commands/reddit.ts @@ -0,0 +1,39 @@ +import { type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { listing, subreddits } from "../api.ts"; +import type { Sort } from "../lib.ts"; +import { loadPosts, postItem, SORTS, subredditList, subUrl } from "../views.ts"; + +let note = ""; +let names: string[] = []; + +export default (ctx: Context) => { + const nsfw = ctx.preferences.nsfw === true; + return list({ + placeholder: "Filter posts by title, subreddit, author or flair…", + filter: { options: SORTS }, + empty: { + title: "No subreddits", + description: "Add some in the preferences, or with the Reddit Subreddits command", + icon: Icons.comment, + }, + status: (items) => + note || + `${items.length} posts · ${ + names.length > 3 ? `${names.length} subreddits` : names.map((n) => `r/${n}`).join(", ") + }`, + items: async ({ filter, signal }) => { + const sort = (SORTS.some((s) => s.value === filter) ? filter : "hot") as Sort; + names = (await subreddits(ctx.preferences)).map((s) => s.name); + if (!names.length) return []; + const items = await loadPosts( + () => listing(names, sort, nsfw, signal), + subUrl(names.join("+"), sort), + (p) => postItem(p, { multi: names.length > 1, browse: (sub) => subredditList(sub, nsfw) }), + (t) => (note = t), + ); + const top = items.find((i) => i.id !== "refused"); + if (sort === "hot" && top) updateCommandMetadata({ subtitle: top.title.slice(0, 60) }).catch(() => {}); + return items; + }, + }); +}; diff --git a/extensions/reddit/commands/subreddits.ts b/extensions/reddit/commands/subreddits.ts new file mode 100644 index 0000000..6491371 --- /dev/null +++ b/extensions/reddit/commands/subreddits.ts @@ -0,0 +1,69 @@ +import { action, type Context, copy, Icons, type Item, list, meta, open, refresh, toast } from "@backslash/api"; +import { addSubreddit, removeSubreddit, subreddits } from "../api.ts"; +import { subredditName } from "../lib.ts"; +import { subredditList, subUrl } from "../views.ts"; + +export default (ctx: Context) => { + const nsfw = ctx.preferences.nsfw === true; + return list({ + placeholder: "Filter your subreddits, or type one to add it…", + empty: { title: "No subreddits yet", description: "Type a subreddit's name to add it", icon: Icons.hash }, + status: (items) => { + const n = items.filter((i) => i.id !== "add").length; + return `${n} subreddit${n === 1 ? "" : "s"}`; + }, + async search(query) { + const subs = await subreddits(ctx.preferences); + const q = query.trim().replace(/^\/?r\//i, "").toLowerCase(); + const shown = subs.filter((s) => s.name.toLowerCase().includes(q)); + const items: Item[] = shown.map((s) => ({ + id: s.name, + title: `r/${s.name}`, + subtitle: s.fromPrefs ? "From the preferences" : "Added from the launcher", + icon: { source: Icons.hash, tint: "orange" }, + detail: { + markdown: `**r/${s.name}**\n\nEnter shows its hot, new and top posts.${ + s.fromPrefs ? "\n\nSet in **Settings → Extensions → Reddit → Configure**." : "" + }`, + metadata: [meta.link("Reddit", `reddit.com/r/${s.name}`, `https://www.reddit.com/r/${s.name}/`)], + }, + actions: [ + action("Browse Posts", () => subredditList(s.name, nsfw), { icon: Icons.comment }), + open(subUrl(s.name), "Open in Browser"), + copy(`https://www.reddit.com/r/${s.name}/`, "Copy Link", { section: "Copy" }), + ...(s.fromPrefs ? [] : [ + action("Remove Subreddit", async () => { + await removeSubreddit(s.name); + await toast(`r/${s.name} removed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Remove r/${s.name}?`, + section: "Danger", + shortcut: "<Ctrl>Delete", + }), + ]), + ], + })); + const name = subredditName(query); + if (name && !subs.some((s) => s.name.toLowerCase() === name.toLowerCase())) { + items.unshift({ + id: "add", + title: `Add r/${name}`, + subtitle: "Follow it in the Reddit command", + icon: { source: Icons.plus, tint: "green" }, + actions: [ + action(`Add r/${name}`, async () => { + await addSubreddit(name); + await toast(`r/${name} added`, { style: "success" }); + return refresh(); + }, { icon: Icons.plus }), + action(`Browse r/${name}`, () => subredditList(name, nsfw), { icon: Icons.comment }), + ], + }); + } + return items; + }, + }); +}; diff --git a/extensions/reddit/lib.ts b/extensions/reddit/lib.ts new file mode 100644 index 0000000..6088c09 --- /dev/null +++ b/extensions/reddit/lib.ts @@ -0,0 +1,305 @@ +// Reddit listings and comments → posts, and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +interface Image { + url: string; + width: number; + height: number; +} + +/** A `t3` (link) of a listing, with `raw_json=1` (no HTML escaping). */ +export interface RedditPost { + id: string; + name?: string; + title: string; + subreddit: string; + author: string; + score: number; + num_comments: number; + created_utc: number; + permalink: string; + url?: string; + url_overridden_by_dest?: string; + domain?: string; + is_self?: boolean; + is_video?: boolean; + is_gallery?: boolean; + selftext?: string; + link_flair_text?: string | null; + link_flair_background_color?: string | null; + over_18?: boolean; + spoiler?: boolean; + stickied?: boolean; + locked?: boolean; + post_hint?: string; + upvote_ratio?: number; + thumbnail?: string; + preview?: { images?: { source: Image; resolutions?: Image[] }[] }; +} + +export interface RedditComment { + id: string; + author: string; + body?: string; + score: number; + created_utc: number; + stickied?: boolean; + distinguished?: string | null; + is_submitter?: boolean; +} + +export interface Listing<T> { + kind: "Listing"; + data: { after?: string | null; children: { kind: string; data: T }[] }; +} + +export interface Post { + id: string; + title: string; + subreddit: string; + author: string; + score: number; + comments: number; + created: number; + permalink: string; + /** The link, for link posts. */ + link?: string; + domain?: string; + self: boolean; + text?: string; + flair?: string; + flairColor?: string; + nsfw: boolean; + spoiler: boolean; + pinned: boolean; + locked: boolean; + kind: "text" | "image" | "video" | "gallery" | "link"; + image?: string; + ratio?: number; +} + +export interface Comment { + author: string; + body: string; + score: number; + created: number; + op: boolean; + mod: boolean; +} + +// --------------------------------------------------------------------------- +// Subreddits + +/** `"r/Linux, programming /r/rust"` → `["Linux", "programming", "rust"]`, deduplicated without case. */ +export function parseSubreddits(text: unknown): string[] { + const seen = new Set<string>(); + return String(text ?? "").split(/[\s,;+]+/).map((s) => s.replace(/^\/?r\//i, "").replace(/\/+$/, "").trim()) + .filter((s) => { + if (!/^[A-Za-z0-9][A-Za-z0-9_]{1,20}$/.test(s) || seen.has(s.toLowerCase())) return false; + seen.add(s.toLowerCase()); + return true; + }); +} + +/** A subreddit typed by the user, or undefined when it can't be one. */ +export const subredditName = (text: string) => parseSubreddits(text)[0]; + +export type Sort = "hot" | "new" | "top"; + +export function listingPath(subs: string[], sort: Sort, limit = 50): string { + const q = new URLSearchParams({ limit: String(limit), raw_json: "1" }); + if (sort === "top") q.set("t", "day"); + return `/r/${subs.map(encodeURIComponent).join("+")}/${sort}.json?${q}`; +} + +// --------------------------------------------------------------------------- +// Posts + +const IMAGE_HOSTS = ["i.redd.it", "preview.redd.it", "external-preview.redd.it"]; + +const hostOf = (url: string) => { + try { + return new URL(url).hostname; + } catch { + return ""; + } +}; + +/** A picture the launcher may show: on Reddit's image hosts, not too big. */ +export function pictureOf(p: RedditPost): string | undefined { + const img = p.preview?.images?.[0]; + if (img) { + const fit = [...(img.resolutions ?? [])].filter((r) => r.width <= 960).sort((a, b) => b.width - a.width)[0]; + const url = (fit ?? img.source).url; + if (IMAGE_HOSTS.includes(hostOf(url))) return url; + } + const direct = p.url_overridden_by_dest ?? p.url; + if (direct && hostOf(direct) === "i.redd.it" && /\.(jpe?g|png|gif)$/i.test(direct)) return direct; + return undefined; +} + +export function fromReddit(p: RedditPost): Post { + const self = !!p.is_self; + const link = self ? undefined : p.url_overridden_by_dest ?? p.url; + const kind: Post["kind"] = self + ? "text" + : p.is_gallery + ? "gallery" + : p.is_video || p.post_hint === "hosted:video" || p.post_hint === "rich:video" + ? "video" + : p.post_hint === "image" || (link && hostOf(link) === "i.redd.it") + ? "image" + : "link"; + return { + id: p.id, + title: p.title.trim(), + subreddit: p.subreddit, + author: p.author, + score: p.score, + comments: p.num_comments, + created: p.created_utc * 1000, + permalink: `https://www.reddit.com${p.permalink}`, + link, + domain: self ? undefined : p.domain, + self, + text: p.selftext?.trim() || undefined, + flair: p.link_flair_text?.trim() || undefined, + flairColor: p.link_flair_background_color || undefined, + nsfw: !!p.over_18, + spoiler: !!p.spoiler, + pinned: !!p.stickied, + locked: !!p.locked, + kind, + image: p.over_18 || p.spoiler ? undefined : pictureOf(p), + ratio: p.upvote_ratio, + }; +} + +export function postsOf(listing: Listing<RedditPost> | undefined, nsfw = false): Post[] { + return (listing?.data?.children ?? []).filter((c) => c.kind === "t3").map((c) => fromReddit(c.data)) + .filter((p) => nsfw || !p.nsfw); +} + +/** `/comments/<id>.json` → the top-level comments, best first, without removed ones and bots' pins. */ +export function commentsOf(answer: [unknown, Listing<RedditComment>] | undefined, limit = 8): Comment[] { + const children = answer?.[1]?.data?.children ?? []; + return children.filter((c) => c.kind === "t1").map((c) => c.data) + .filter((c) => c.body && c.body !== "[removed]" && c.body !== "[deleted]" && !(c.stickied && c.distinguished)) + .slice(0, limit) + .map((c) => ({ + author: c.author, + body: c.body!.trim(), + score: c.score, + created: c.created_utc * 1000, + op: !!c.is_submitter, + mod: c.distinguished === "moderator", + })); +} + +// --------------------------------------------------------------------------- +// Formatting + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +/** "5 min", "3 h", "2 d", then a date. */ +export function ago(ms: number, now = Date.now()): string { + const s = Math.max(0, Math.round((now - ms) / 1000)); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (s < 86400) return `${Math.floor(s / 3600)} h`; + if (s < 30 * 86400) return `${Math.floor(s / 86400)} d`; + return new Date(ms).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +/** "Wed 28 Oct, 05:37". */ +export const fullDate = (ms: number) => + new Date(ms).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); + +/** A flair's background color → the nearest launcher color. */ +export function flairColor(hex: string | undefined): Color { + const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const n = parseInt(m[1], 16); + const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max - min < 30) return "gray"; + const d = max - min; + let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + h = (h * 60 + 360) % 360; + if (h < 15 || h >= 345) return "red"; + if (h < 40) return "orange"; + if (h < 65) return "yellow"; + if (h < 160) return "green"; + if (h < 195) return "teal"; + if (h < 255) return "blue"; + if (h < 290) return "purple"; + return "pink"; +} + +/** Reddit markdown → the launcher's subset: spoilers hidden, superscripts and tables flattened. */ +export function cleanMarkdown(text: string, max = 1500): string { + let out = text + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") + .replace(/>!(.+?)!</g, "[spoiler]") + .replace(/\^\(([^)]*)\)/g, "$1") + .replace(/(^|\s)\^(\S+)/g, "$1$2") + .replace(/^\|?\s*:?-{3,}.*$/gm, "") + .replace(/​/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + if (out.length > max) out = out.slice(0, max).replace(/\s+\S*$/, "") + "…"; + return out; +} + +export function commentsMarkdown(p: Post, comments: Comment[]): string { + const head = [`# ${p.title}`, "", `r/${p.subreddit} · u/${p.author} · ${ago(p.created)}`]; + if (p.text) head.push("", cleanMarkdown(p.text, 1200)); + if (!comments.length) return [...head, "", "---", "", "No comments yet."].join("\n"); + const body = comments.map((c) => + [ + `**u/${c.author}**${c.op ? " (OP)" : ""}${c.mod ? " (mod)" : ""} · ${compact(c.score)} points · ${ + ago(c.created) + }`, + "", + cleanMarkdown(c.body, 700), + ].join("\n") + ); + return [...head, "", "---", "", body.join("\n\n---\n\n")].join("\n"); +} + +/** Why Reddit refused, from the status and its JSON body. */ +export function refusal( + status: number, + body: unknown, + retryAfter?: string | null, +): { message: string; blocked: boolean } { + const reason = (body as { reason?: string } | undefined)?.reason; + if (status === 404 || reason === "banned") { + return { message: "This subreddit doesn't exist or was banned", blocked: false }; + } + if (reason === "private") return { message: "This subreddit is private", blocked: false }; + if (reason === "quarantined") { + return { message: "This subreddit is quarantined (only viewable signed in)", blocked: false }; + } + if (status === 429) { + const wait = Number(retryAfter); + return { + message: `Reddit is rate limiting${wait ? `: try again in ${Math.ceil(wait)} s` : ", try again in a minute"}`, + blocked: true, + }; + } + if (status === 403) return { message: "Reddit blocked this request from your network", blocked: true }; + return { message: `Reddit answered ${status}`, blocked: status >= 500 }; +} diff --git a/extensions/reddit/lib_test.ts b/extensions/reddit/lib_test.ts new file mode 100644 index 0000000..31f99ea --- /dev/null +++ b/extensions/reddit/lib_test.ts @@ -0,0 +1,214 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + cleanMarkdown, + commentsMarkdown, + commentsOf, + flairColor, + fromReddit, + type Listing, + listingPath, + parseSubreddits, + pictureOf, + postsOf, + type RedditComment, + type RedditPost, + refusal, + subredditName, +} from "./lib.ts"; +import { postItem, refusedItem } from "./views.ts"; +import { Refused } from "./api.ts"; + +const NOW = Date.parse("2026-10-28T10:00:00Z"); + +// Shaped like `/r/<sub>/hot.json?raw_json=1` children. +const link: RedditPost = { + id: "1abcde", + name: "t3_1abcde", + title: "Linux 7.2 released ", + subreddit: "linux", + author: "linus_fan", + score: 1523, + num_comments: 214, + created_utc: NOW / 1000 - 5 * 3600, + permalink: "/r/linux/comments/1abcde/linux_72_released/", + url: "https://lkml.org/lkml/2026/10/26/1", + url_overridden_by_dest: "https://lkml.org/lkml/2026/10/26/1", + domain: "lkml.org", + is_self: false, + link_flair_text: "Kernel", + link_flair_background_color: "#0079d3", + post_hint: "link", + upvote_ratio: 0.97, + preview: { + images: [{ + source: { url: "https://external-preview.redd.it/abc.jpg?auto=webp&s=1", width: 1200, height: 630 }, + resolutions: [ + { url: "https://external-preview.redd.it/abc.jpg?width=320&s=2", width: 320, height: 168 }, + { url: "https://external-preview.redd.it/abc.jpg?width=640&s=3", width: 640, height: 336 }, + { url: "https://external-preview.redd.it/abc.jpg?width=1080&s=4", width: 1080, height: 567 }, + ], + }], + }, +}; +const image: RedditPost = { + ...link, + id: "2fghij", + title: "My Hyprland setup", + subreddit: "unixporn", + url: "https://i.redd.it/xyz123.png", + url_overridden_by_dest: "https://i.redd.it/xyz123.png", + domain: "i.redd.it", + post_hint: "image", + preview: undefined, +}; +const self: RedditPost = { + ...link, + id: "3klmno", + title: "Weekly questions thread", + is_self: true, + domain: "self.linux", + url: "https://www.reddit.com/r/linux/comments/3klmno/x/", + url_overridden_by_dest: undefined, + selftext: "Ask anything!", + stickied: true, + link_flair_text: null, + preview: undefined, +}; +const nsfw: RedditPost = { ...image, id: "4nsfwx", over_18: true }; + +Deno.test("subreddit names", () => { + assertEquals(parseSubreddits("linux, r/programming /r/rust/ Linux;technology"), [ + "linux", + "programming", + "rust", + "technology", + ]); + assertEquals(parseSubreddits("a, -bad-, ok_one"), ["ok_one"]); + assertEquals(parseSubreddits(undefined), []); + assertEquals(subredditName(" r/Rust "), "Rust"); + assertEquals(subredditName("not a sub!"), "not"); + assertEquals(listingPath(["linux", "rust"], "hot"), "/r/linux+rust/hot.json?limit=50&raw_json=1"); + assertEquals(listingPath(["linux"], "top", 25), "/r/linux/top.json?limit=25&raw_json=1&t=day"); +}); + +Deno.test("posts", () => { + const p = fromReddit(link); + assertEquals(p.title, "Linux 7.2 released"); + assertEquals(p.kind, "link"); + assertEquals(p.link, "https://lkml.org/lkml/2026/10/26/1"); + assertEquals(p.permalink, "https://www.reddit.com/r/linux/comments/1abcde/linux_72_released/"); + assertEquals(p.image, "https://external-preview.redd.it/abc.jpg?width=640&s=3"); + const i = fromReddit(image); + assertEquals(i.kind, "image"); + assertEquals(i.image, "https://i.redd.it/xyz123.png"); + const s = fromReddit(self); + assertEquals([s.kind, s.self, s.link, s.domain, s.pinned, s.flair], [ + "text", + true, + undefined, + undefined, + true, + undefined, + ]); + assertEquals(fromReddit(nsfw).image, undefined); + assertEquals( + pictureOf({ + ...link, + preview: { images: [{ source: { url: "https://evil.example/x.jpg", width: 1, height: 1 } }] }, + }), + undefined, + ); + const listing: Listing<RedditPost> = { + kind: "Listing", + data: { children: [{ kind: "t3", data: link }, { kind: "t3", data: nsfw }, { kind: "t1", data: self }] }, + }; + assertEquals(postsOf(listing).map((p) => p.id), ["1abcde"]); + assertEquals(postsOf(listing, true).map((p) => p.id), ["1abcde", "4nsfwx"]); +}); + +Deno.test("comments", () => { + const listing: Listing<RedditComment> = { + kind: "Listing", + data: { + children: [ + { + kind: "t1", + data: { + id: "0", + author: "AutoModerator", + body: "Rules", + score: 1, + created_utc: 0, + stickied: true, + distinguished: "moderator", + }, + }, + { + kind: "t1", + data: { id: "1", author: "gregkh", body: "Great release ", score: 900, created_utc: NOW / 1000 - 3000 }, + }, + { + kind: "t1", + data: { id: "2", author: "op", body: "Thanks", score: 5, created_utc: NOW / 1000, is_submitter: true }, + }, + { kind: "t1", data: { id: "3", author: "[deleted]", body: "[removed]", score: 1, created_utc: 0 } }, + { kind: "more", data: { id: "x", author: "", score: 0, created_utc: 0 } }, + ], + }, + }; + const c = commentsOf([{}, listing]); + assertEquals(c.map((x) => [x.author, x.body, x.op]), [["gregkh", "Great release", false], ["op", "Thanks", true]]); + const md = commentsMarkdown(fromReddit(self), c); + assertEquals(md.includes("**u/op** (OP) · 5 points"), true); + assertEquals(md.includes("Ask anything!"), true); + assertEquals(commentsMarkdown(fromReddit(link), []).endsWith("No comments yet."), true); +}); + +Deno.test("formatting", () => { + assertEquals(ago(NOW - 5 * 3600_000, NOW), "5 h"); + assertEquals(ago(NOW - 30_000, NOW), "now"); + assertEquals(flairColor("#0079d3"), "blue"); + assertEquals(flairColor("#ff4500"), "orange"); + assertEquals(flairColor("#46d160"), "green"); + assertEquals(flairColor("#dadada"), "gray"); + assertEquals(flairColor(""), "gray"); + assertEquals( + cleanMarkdown("Hi >!spoiler!< ^(small) & more\n\n\n\n|a|\n|---|\n|1|"), + "Hi [spoiler] small & more\n\n|a|\n\n|1|", + ); + assertEquals(cleanMarkdown("word ".repeat(400), 20), "word word word word…"); +}); + +Deno.test("refusals", () => { + assertEquals(refusal(403, { reason: "private", message: "Forbidden", error: 403 }), { + message: "This subreddit is private", + blocked: false, + }); + assertEquals(refusal(403, undefined), { message: "Reddit blocked this request from your network", blocked: true }); + assertEquals(refusal(429, undefined, "42"), { message: "Reddit is rate limiting: try again in 42 s", blocked: true }); + assertEquals(refusal(404, { reason: "banned" }).blocked, false); + assertEquals(refusal(503, undefined), { message: "Reddit answered 503", blocked: true }); + const item = refusedItem( + new Refused("Reddit blocked this request from your network", true), + "https://www.reddit.com/r/linux/hot", + ); + assertEquals(item.actions?.map((a) => a.title), ["Open in Browser", "Try Again"]); +}); + +Deno.test("post item", () => { + const item = postItem(fromReddit(link), { multi: true, browse: () => ({}) as never }); + assertEquals(item.subtitle, "r/linux"); + assertEquals(item.icon, { source: "bs-link", tint: "orange" }); + assertEquals(item.detail?.image, "https://external-preview.redd.it/abc.jpg?width=640&s=3"); + assertEquals(item.accessories?.map((a) => a.tag ?? a.text).slice(0, 3), ["Kernel", "1.5K", "214"]); + assertEquals(item.actions?.map((a) => a.title).slice(0, 4), [ + "Open Post", + "Open Link", + "Show Top Comments", + "Browse r/linux", + ]); + const one = postItem(fromReddit(self), { multi: false }); + assertEquals(one.subtitle, "u/linus_fan"); + assertEquals(one.actions?.map((a) => a.title).slice(0, 3), ["Open Post", "Show Top Comments", "Open u/linus_fan"]); +}); diff --git a/extensions/reddit/views.ts b/extensions/reddit/views.ts new file mode 100644 index 0000000..c06912e --- /dev/null +++ b/extensions/reddit/views.ts @@ -0,0 +1,190 @@ +// Posts as launcher items, the comments page and a subreddit's list. + +import { + type Accessory, + type Action, + action, + type Color, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, +} from "@backslash/api"; +import { comments, listing, type Loaded, Refused } from "./api.ts"; +import { ago, cleanMarkdown, commentsMarkdown, compact, flairColor, fullDate, type Post, type Sort } from "./lib.ts"; + +export const SORTS: { value: Sort; title: string }[] = [ + { value: "hot", title: "Hot" }, + { value: "new", title: "New" }, + { value: "top", title: "Top Today" }, +]; + +const KIND_ICONS: Record<Post["kind"], string> = { + text: Icons.text, + image: Icons.image, + video: Icons.video, + gallery: Icons.layers, + link: Icons.link, +}; + +export const subUrl = (sub: string, sort: Sort = "hot") => + `https://www.reddit.com/r/${encodeURIComponent(sub)}/${sort === "top" ? "top/?t=day" : sort}`; + +export async function commentsView(p: Post, signal: AbortSignal): Promise<DetailSpec> { + const top = await comments(p.id, signal); + return detail({ + title: p.title, + markdown: commentsMarkdown(p, top), + status: `${compact(p.score)} points · ${compact(p.comments)} comments · r/${p.subreddit}`, + metadata: postMetadata(p), + actions: [ + open(p.permalink, "Open Post on Reddit"), + ...(p.link ? [open(p.link, "Open Link")] : []), + copy(p.permalink, "Copy Post Link", { section: "Copy" }), + ], + }); +} + +function postMetadata(p: Post): Metadata[] { + const lines: Metadata[] = [ + meta.text("Subreddit", `r/${p.subreddit}`, { icon: Icons.hash }), + meta.person("Author", `u/${p.author}`, p.author), + meta.text("Posted", `${ago(p.created)} · ${fullDate(p.created)}`, { icon: Icons.clock }), + ]; + const badges: { text: string; color?: Color }[] = []; + if (p.flair) badges.push({ text: p.flair, color: flairColor(p.flairColor) }); + if (p.nsfw) badges.push({ text: "NSFW", color: "red" }); + if (p.spoiler) badges.push({ text: "Spoiler", color: "orange" }); + if (p.pinned) badges.push({ text: "Pinned", color: "green" }); + if (p.locked) badges.push({ text: "Locked", color: "gray" }); + if (badges.length) lines.push(meta.tags("Flair", badges)); + if (p.ratio !== undefined) lines.push(meta.progress("Upvoted", p.ratio)); + if (p.link && p.domain) lines.push(meta.link("Link", p.domain, p.link)); + lines.push(meta.link("Post", `reddit.com/r/${p.subreddit}/comments/${p.id}`, p.permalink)); + return lines; +} + +export function postItem(p: Post, o: { multi: boolean; browse?: (sub: string) => ListSpec }): Item { + const accessories: Accessory[] = []; + if (p.pinned) accessories.push({ icon: { source: Icons.pin, tint: "green" }, tooltip: "Pinned by the moderators" }); + if (p.flair) accessories.push({ tag: p.flair.slice(0, 20), color: flairColor(p.flairColor) }); + accessories.push( + { + text: compact(p.score), + icon: { source: Icons.arrowUp, tint: p.score >= 1000 ? "orange" : "gray" }, + tooltip: `${p.score} points`, + }, + { text: compact(p.comments), icon: Icons.comment, tooltip: `${p.comments} comments` }, + { text: ago(p.created), tooltip: fullDate(p.created) }, + ); + const actions: Action[] = [ + open(p.permalink, "Open Post"), + ...(p.link ? [open(p.link, "Open Link", { shortcut: "<Ctrl>o" })] : []), + action("Show Top Comments", (ctx) => commentsView(p, ctx.signal), { icon: Icons.comment, shortcut: "<Ctrl>t" }), + ...(o.multi && o.browse + ? [action(`Browse r/${p.subreddit}`, () => o.browse!(p.subreddit), { icon: Icons.hash, shortcut: "<Ctrl>r" })] + : []), + open(`https://www.reddit.com/user/${encodeURIComponent(p.author)}`, `Open u/${p.author}`, { + icon: Icons.person, + section: "Open", + }), + copy(p.link ?? p.permalink, "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + ...(p.link ? [copy(p.permalink, "Copy Post Link", { section: "Copy" })] : []), + copy(`[${p.title}](${p.permalink})`, "Copy as Markdown Link", { section: "Copy" }), + ]; + const text = p.text && !p.spoiler ? cleanMarkdown(p.text, 900) : undefined; + return { + id: p.id, + title: p.title, + subtitle: o.multi ? `r/${p.subreddit}` : `u/${p.author}`, + icon: { source: KIND_ICONS[p.kind], tint: p.nsfw ? "red" : p.pinned ? "green" : "orange" }, + keywords: [p.subreddit, p.author, p.flair ?? "", p.domain ?? ""], + accessories, + detail: { + image: p.image, + stats: [ + { label: "Points", value: compact(p.score), color: p.score >= 1000 ? "orange" : undefined }, + { label: "Comments", value: compact(p.comments) }, + ...(p.ratio !== undefined ? [{ label: "Upvoted", value: `${Math.round(p.ratio * 100)} %` }] : []), + ], + markdown: [`**${p.title}**`, ...(text ? ["", text] : p.spoiler ? ["", "*Spoiler hidden*"] : [])].join("\n"), + metadata: postMetadata(p), + }, + actions, + }; +} + +/** Shown when Reddit refuses and nothing is saved, or for a subreddit that can't be read. */ +export function refusedItem(e: Refused, webUrl: string): Item { + return { + id: "refused", + title: e.message, + subtitle: e.blocked ? "Reddit limits requests without an account" : undefined, + icon: { source: e.blocked ? Icons.shield : Icons.alert, tint: "orange" }, + detail: { + markdown: e.blocked + ? [ + "## Reddit refused the request", + "", + "Reddit's public pages are rate limited, and refused outright from some networks", + "(VPNs, cloud servers, some ISPs). This usually passes after a minute;", + "otherwise open Reddit in the browser (Enter).", + ].join("\n") + : `## ${e.message}`, + }, + actions: [ + open(webUrl, "Open in Browser"), + action("Try Again", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r" }), + ], + }; +} + +/** Posts of a listing, or the refusal; `note` receives a status for saved copies. */ +export async function loadPosts( + load: () => Promise<Loaded>, + webUrl: string, + item: (p: Post) => Item, + note: (text: string) => void, +): Promise<Item[]> { + try { + const { posts, savedAt, refused } = await load(); + const age = savedAt && ago(savedAt); + note(savedAt ? `${refused?.message ?? "Reddit refused"} · posts from ${age === "now" ? "a moment" : age} ago` : ""); + return posts.map(item); + } catch (e) { + if (e instanceof Refused) { + note(e.message); + return [refusedItem(e, webUrl)]; + } + throw e; + } +} + +/** One subreddit, with the same Hot / New / Top views. */ +export function subredditList(sub: string, nsfw: boolean): ListSpec { + let note = ""; + return list({ + title: `r/${sub}`, + placeholder: `Filter r/${sub} posts by title, author or flair…`, + filter: { options: SORTS }, + empty: { title: "No posts", description: `r/${sub} has nothing here`, icon: Icons.comment }, + status: (items) => note || `${items.length} posts · r/${sub}`, + actions: [open(subUrl(sub), `Open r/${sub} in Browser`)], + items: ({ filter, signal }) => { + const sort = (SORTS.some((s) => s.value === filter) ? filter : "hot") as Sort; + return loadPosts( + () => listing([sub], sort, nsfw, signal), + subUrl(sub, sort), + (p) => postItem(p, { multi: false }), + (t) => (note = t), + ); + }, + }); +} diff --git a/extensions/regex/backslash.toml b/extensions/regex/backslash.toml new file mode 100644 index 0000000..4b1c3b4 --- /dev/null +++ b/extensions/regex/backslash.toml @@ -0,0 +1,28 @@ +[extension] +id = "regex" +title = "Regex Tester" +description = "Test JavaScript regular expressions with groups, replacements, explanations and a library of common patterns" +version = "1.0.0" +author = "Backslash" +icon = "bs-braces" +license = "MIT" + +# No permission: patterns run locally on text you type or save. + +[[commands]] +name = "regex" +title = "Regex Tester" +description = "Type /pattern/flags and a text: matches, numbered and named groups, replace preview, token by token" +icon = "bs-braces" +keywords = [ + "regex", + "regexp", + "regular expression", + "pattern", + "match", + "replace", + "test", + "expression régulière", + "motif", + "remplacer", +] diff --git a/extensions/regex/commands/regex.ts b/extensions/regex/commands/regex.ts new file mode 100644 index 0000000..4104d35 --- /dev/null +++ b/extensions/regex/commands/regex.ts @@ -0,0 +1,333 @@ +import { + type Action, + action, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + replace, + storage, +} from "@backslash/api"; +import { + compile, + constructor, + context, + explainTokens, + findMatches, + FLAGS, + groupNames, + highlight, + LIBRARY, + literal, + parseQuery, + type Query, + type Recipe, + replacePreview, + SAMPLE_TEXT, +} from "../lib.ts"; + +interface Saved { + text: string; + replacement?: string; +} + +async function saved(): Promise<Saved> { + const text = await storage.get<string>("text").catch(() => undefined); + const replacement = await storage.get<string>("replacement").catch(() => undefined); + return { text: text ?? SAMPLE_TEXT, replacement: replacement ?? undefined }; +} + +const fence = (text: string) => { + const shown = text.length > 8000 ? `${text.slice(0, 8000)}\n…` : text; + return `\`\`\`\n${shown}\n\`\`\``; +}; +const oneLine = (text: string, max = 90) => { + const line = text.replace(/\n/g, "↵").replace(/\t/g, "→"); + return line.length > max ? `${line.slice(0, max)}…` : line; +}; +const plural = (n: number, one: string, many = `${one}es`) => `${n} ${n === 1 ? one : many}`; + +function editForm(q: Query | undefined, s: Saved): FormSpec { + return form({ + title: "Test Text", + submitTitle: "Test", + fields: [ + field.text("pattern", "Pattern", { value: q?.pattern ?? "", placeholder: String.raw`\b\w+@\w+\.\w+\b` }), + field.text("flags", "Flags", { value: q?.flags ?? "g", placeholder: "g, i, m, s, u, y, d, v" }), + field.textArea("text", "Test text", { value: q?.text ?? s.text, required: true }), + field.text("replacement", "Replacement", { + value: q?.replacement ?? s.replacement ?? "", + placeholder: "Optional: $1, $<name>, $& (empty for no preview)", + }), + ], + async submit(values) { + const text = String(values.text ?? ""); + const replacement = String(values.replacement ?? "") || undefined; + await storage.set("text", text); + if (replacement === undefined) await storage.delete("replacement"); + else await storage.set("replacement", replacement); + const pattern = String(values.pattern ?? ""); + const next: Query = { pattern, flags: String(values.flags ?? "").trim(), literal: true, text, replacement }; + return replace(pattern ? resultsList(next, { text, replacement }) : home({ text, replacement })); + }, + }); +} + +function patternActions(pattern: string, flags: string): Action[] { + return [ + copy(literal(pattern, flags), "Copy as Regex Literal"), + copy(pattern, "Copy Pattern", { shortcut: "<Ctrl><Shift>c" }), + copy(constructor(pattern, flags), "Copy as new RegExp()", { icon: Icons.code, section: "Copy" }), + copy(JSON.stringify(pattern), "Copy as JSON String", { icon: Icons.braces, section: "Copy" }), + ]; +} + +function resultItems(q: Query, s: Saved): Item[] { + const text = q.text ?? s.text; + const replacement = q.replacement ?? s.replacement; + const edit = action("Edit Pattern and Test Text…", () => editForm(q, s), { icon: Icons.text, shortcut: "<Ctrl>e" }); + const tokens = explainTokens(q.pattern, q.flags); + const tokenItems: Item[] = tokens.slice(0, 60).map((t, i) => ({ + id: `token:${i}`, + title: t.text, + subtitle: t.description, + icon: { source: Icons.chevronRight, tint: t.kind === "group" ? "purple" : t.kind === "class" ? "teal" : "gray" }, + section: "Explanation", + actions: [copy(t.text, "Copy Token"), edit], + })); + let re: RegExp; + try { + re = compile(q.pattern, q.flags); + } catch (e) { + return [{ + id: "error", + title: (e as Error).message, + subtitle: literal(q.pattern, q.flags), + icon: { source: Icons.alert, tint: "red" }, + section: "Result", + detail: { markdown: `## Invalid pattern\n\n${(e as Error).message}\n\n${fence(literal(q.pattern, q.flags))}` }, + actions: [edit, copy(q.pattern, "Copy Pattern")], + }, ...tokenItems]; + } + const { matches, truncated } = findMatches(re, text); + const groupCount = groupNames(q.pattern).length; + const all = re.global || re.sticky; + const shownFlags = q.flags.replace("d", ""); + const flagTags = [...q.flags].map((f) => ({ text: f, color: "blue" as const })); + const count = `${truncated ? `${matches.length}+` : matches.length}`; + const items: Item[] = [{ + id: "summary", + title: matches.length ? `${count} ${matches.length === 1 ? "match" : "matches"}` : "No match", + subtitle: literal(q.pattern, shownFlags), + icon: { source: matches.length ? Icons.check : Icons.cross, tint: matches.length ? "green" : "gray" }, + section: "Result", + accessories: [ + ...(!all && matches.length + ? [{ tag: "first only", color: "orange" as const, tooltip: "Add the g flag for all" }] + : []), + { text: q.text ? "typed text" : "saved text", tooltip: "Test text" }, + ], + detail: { + stats: [ + { label: "Matches", value: count, color: matches.length ? "green" : "gray" }, + { label: "Groups", value: groupCount }, + { label: "Characters", value: [...text].length }, + ], + markdown: `${fence(highlight(text, matches))}${ + !all && matches.length ? "\n\nOnly the first match: add the **g** flag to find them all." : "" + }`, + metadata: [ + meta.text("Pattern", literal(q.pattern, shownFlags), { icon: Icons.code }), + ...(flagTags.length ? [meta.tags("Flags", flagTags)] : []), + ...[...q.flags].filter((f) => FLAGS[f]).map((f) => meta.text(f, FLAGS[f])), + ], + }, + actions: [ + ...patternActions(q.pattern, shownFlags), + edit, + ...(matches.length + ? [copy(matches.map((m) => m.text).join("\n"), "Copy All Matches", { icon: Icons.layers, section: "Copy" })] + : []), + ], + }]; + if (replacement !== undefined) { + const out = replacePreview(re, text, replacement); + items.push({ + id: "replace", + title: oneLine(out) || "(empty)", + subtitle: `→ ${JSON.stringify(replacement)}`, + icon: { source: Icons.repeat, tint: "purple" }, + section: "Replace", + detail: { + markdown: `${fence(out)}\n\n\`replace(${literal(q.pattern, shownFlags)}, ${JSON.stringify(replacement)})\`${ + all ? "" : "\n\nWithout **g**, only the first match is replaced." + }`, + }, + actions: [ + copy(out, "Copy Result"), + copy(`.replace(${literal(q.pattern, shownFlags)}, ${JSON.stringify(replacement)})`, "Copy replace() Call", { + icon: Icons.code, + }), + edit, + ], + }); + } + matches.slice(0, 100).forEach((m, i) => { + const groups = m.groups.map((g) => ({ + label: g.name ? `$${g.number} (${g.name})` : `$${g.number}`, + value: g.value === undefined ? "(not matched)" : g.value === "" ? "(empty)" : oneLine(g.value, 60), + })); + const json = JSON.stringify( + { + match: m.text, + index: m.index, + groups: m.groups.map((g) => g.value ?? null), + ...(m.groups.some((g) => g.name) + ? { named: Object.fromEntries(m.groups.filter((g) => g.name).map((g) => [g.name, g.value ?? null])) } + : {}), + }, + null, + 2, + ); + items.push({ + id: `match:${i}`, + title: m.text === "" ? "(empty match)" : oneLine(m.text), + subtitle: `${m.index}–${m.end}`, + icon: { source: Icons.dot, tint: "yellow" }, + section: "Matches", + keywords: m.groups.map((g) => g.value ?? ""), + accessories: m.groups.length + ? [{ + text: plural(m.groups.length, "group", "groups"), + tooltip: groups.map((g) => `${g.label}: ${g.value}`).join("\n"), + }] + : [], + detail: { + markdown: fence(context(text, m)), + metadata: [ + meta.text("Match", oneLine(m.text, 60) || "(empty)", { icon: Icons.search }), + meta.text("Position", `${m.index} to ${m.end} (${m.end - m.index} chars)`), + ...(groups.length ? [meta.separator(), ...groups.map((g) => meta.text(g.label, g.value))] : []), + ], + }, + actions: [ + copy(m.text, "Copy Match"), + ...m.groups.filter((g) => g.value).slice(0, 9).map((g) => + copy(g.value!, `Copy Group ${g.name ? `“${g.name}”` : g.number}`, { section: "Groups" }) + ), + copy(json, "Copy as JSON", { icon: Icons.braces, section: "Copy" }), + ], + }); + }); + if (matches.length > 100) { + items.push({ + id: "more", + title: `${matches.length - 100} more matches`, + icon: { source: Icons.info, tint: "gray" }, + section: "Matches", + actions: [copy(matches.map((m) => m.text).join("\n"), "Copy All Matches")], + }); + } + items.push(...tokenItems, textItem(q, s)); + return items; +} + +function textItem(q: Query | undefined, s: Saved): Item { + const text = q?.text ?? s.text; + return { + id: "text", + title: oneLine(text), + subtitle: q?.text ? "Typed after the pattern" : text === SAMPLE_TEXT ? "Sample text: Enter to use yours" : "Saved", + icon: { source: Icons.text, tint: "blue" }, + section: "Test Text", + accessories: [{ text: plural(text.split("\n").length, "line", "lines") }], + detail: { markdown: fence(text) }, + actions: [ + action("Edit Test Text…", () => editForm(q, s), { icon: Icons.text }), + copy(text, "Copy Test Text"), + action("Reset to Sample Text", async () => { + await storage.delete("text"); + await storage.delete("replacement"); + return replace(home({ text: SAMPLE_TEXT })); + }, { icon: Icons.refresh, section: "Danger", destructive: true, confirm: "Forget your test text?" }), + ], + }; +} + +function recipeItem(r: Recipe, s: Saved): Item { + const re = compile(r.pattern, r.flags); + const { matches } = findMatches(re, r.examples); + const q: Query = { pattern: r.pattern, flags: r.flags, literal: true }; + return { + id: `recipe:${r.id}`, + title: r.name, + subtitle: oneLine(r.pattern, 60), + icon: { source: Icons.braces, tint: "purple" }, + section: "Library", + keywords: [r.description, r.id, ...(r.keywords ?? [])], + accessories: [{ text: `/${r.flags}`, tooltip: [...r.flags].map((f) => FLAGS[f]).join("\n") }], + detail: { + markdown: [ + `## ${r.name}`, + "", + r.description, + "", + fence(literal(r.pattern, r.flags)), + "", + "**Examples**", + "", + fence(highlight(r.examples, matches)), + ].join("\n"), + }, + actions: [ + ...patternActions(r.pattern, r.flags), + action("Test on the Examples", () => resultsList({ ...q, text: r.examples }, s), { + icon: Icons.search, + shortcut: "<Ctrl>t", + section: "Test", + }), + action("Test on My Text", () => resultsList(q, s), { icon: Icons.text, section: "Test" }), + ], + }; +} + +const library = (s: Saved, filter = "") => { + const words = filter.toLowerCase().split(/\s+/).filter(Boolean); + return LIBRARY.filter((r) => { + const hay = `${r.name} ${r.id} ${r.description} ${(r.keywords ?? []).join(" ")}`.toLowerCase(); + return words.every((w) => hay.includes(w)); + }).map((r) => recipeItem(r, s)); +}; + +const status = (items: Item[]) => { + const summary = items.find((i) => i.id === "summary" || i.id === "error"); + if (summary) return `${summary.id === "error" ? "Invalid pattern" : summary.title} · ${summary.subtitle}`; + return `${items.filter((i) => i.section === "Library").length} patterns in the library`; +}; + +function resultsList(q: Query, s: Saved): ListSpec { + return list({ title: literal(q.pattern, q.flags), items: resultItems(q, s), status }); +} + +function home(s: Saved): ListSpec { + return list({ title: "Regex Tester", items: [textItem(undefined, s), ...library(s)], status }); +} + +export default list({ + placeholder: "/pattern/flags test text, s/pattern/replacement/g, or a pattern…", + status, + async search(query) { + const s = await saved(); + const q = parseQuery(query); + if (!q) return [textItem(undefined, s), ...library(s)]; + const items = resultItems(q, s); + // Plain words also look up the library ("email", "uuid"). + if (!q.literal && /^[a-z][a-z0-9 .-]*$/i.test(q.pattern)) items.splice(1, 0, ...library(s, q.pattern)); + return items; + }, +}); diff --git a/extensions/regex/lib.ts b/extensions/regex/lib.ts new file mode 100644 index 0000000..400af94 --- /dev/null +++ b/extensions/regex/lib.ts @@ -0,0 +1,624 @@ +// Regular expressions: query parsing, matching, highlighting and a short explanation. Pure, tested in lib_test.ts. + +export interface Query { + pattern: string; + flags: string; + /** Text typed after the pattern, if any. */ + text?: string; + /** From `s/pattern/replacement/flags`. */ + replacement?: string; + /** Typed between slashes (flags respected as typed). */ + literal: boolean; +} + +/** Index of the next unescaped `/` from `start`, outside character classes; -1 if none. */ +export function closingSlash(s: string, start: number): number { + let inClass = false; + for (let i = start; i < s.length; i++) { + const c = s[i]; + if (c === "\\") i++; + else if (inClass) inClass = c !== "]"; + else if (c === "[") inClass = true; + else if (c === "/") return i; + } + return -1; +} + +function afterFlags(rest: string): { flags: string; text?: string } { + const m = rest.match(/^([a-z]*)(?:\s([\s\S]*))?$/); + if (!m) throw new Error(`Flags go right after the closing slash: “${rest.split(/\s/)[0]}”`); + return { flags: m[1], text: m[2] || undefined }; +} + +/** + * `/pattern/flags test text`, `s/pattern/replacement/flags text`, or a bare + * pattern (tested with the global flag against the saved text). + */ +export function parseQuery(input: string): Query | undefined { + const q = input.replace(/^\s+/, ""); + if (!q.trim()) return undefined; + if (q.startsWith("s/")) { + const end = closingSlash(q, 2); + const repEnd = end < 0 ? -1 : closingSlash(q, end + 1); + if (end > 0 && repEnd > 0) { + const { flags, text } = afterFlags(q.slice(repEnd + 1)); + return { pattern: q.slice(2, end), replacement: q.slice(end + 1, repEnd), flags, text, literal: true }; + } + } + if (q.startsWith("/")) { + const end = closingSlash(q, 1); + if (end > 0) return { pattern: q.slice(1, end), ...afterFlags(q.slice(end + 1)), literal: true }; + // Still typing: `/abc` is the pattern so far. + return { pattern: q.slice(1), flags: "g", literal: true }; + } + return { pattern: q.trimEnd(), flags: "g", literal: false }; +} + +/** "Invalid regular expression: /(/: Unterminated group" → "Unterminated group". */ +export function compile(pattern: string, flags: string): RegExp { + const bad = [...flags].find((f) => !"dgimsuyv".includes(f)); + if (bad) throw new Error(`Unknown flag “${bad}”: use d, g, i, m, s, u, v or y`); + try { + return new RegExp(pattern, flags.includes("d") ? flags : `${flags}d`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error( + message.replace(/^Invalid regular expression: \/.*\/[a-z]*: /s, "").replace(/^Invalid flags.*/, "Invalid flags"), + ); + } +} + +export interface Group { + /** 1-based. */ + number: number; + name?: string; + /** Undefined when the group did not take part in the match. */ + value?: string; + start?: number; + end?: number; +} + +export interface Match { + index: number; + end: number; + text: string; + groups: Group[]; +} + +/** Every match (or just the first without `g`/`y`), at most `limit`. */ +export function findMatches(re: RegExp, text: string, limit = 500): { matches: Match[]; truncated: boolean } { + const names = groupNames(re.source); + const matches: Match[] = []; + const all = re.global || re.sticky; + re.lastIndex = 0; + while (matches.length < limit) { + const m = re.exec(text) as (RegExpExecArray & { indices?: [number, number][] }) | null; + if (!m) break; + const groups: Group[] = []; + for (let i = 1; i < m.length; i++) { + const at = m.indices?.[i]; + groups.push({ number: i, name: names[i - 1], value: m[i], start: at?.[0], end: at?.[1] }); + } + matches.push({ index: m.index, end: m.index + m[0].length, text: m[0], groups }); + if (!all) break; + // Empty matches would loop forever. + if (m[0] === "") { + const cp = text.codePointAt(re.lastIndex) ?? 0; + re.lastIndex += (re.unicode || re.unicodeSets) && cp > 0xffff ? 2 : 1; + } + if (re.lastIndex > text.length) break; + } + const truncated = matches.length === limit && all && re.exec(text) !== null; + re.lastIndex = 0; + return { matches, truncated }; +} + +/** Names of the capture groups in order, `undefined` for numbered ones. */ +export function groupNames(source: string): (string | undefined)[] { + const names: (string | undefined)[] = []; + let inClass = false; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + if (c === "\\") i++; + else if (inClass) inClass = c !== "]"; + else if (c === "[") inClass = true; + else if (c === "(") { + if (source[i + 1] !== "?") names.push(undefined); + else { + const named = source.slice(i).match(/^\(\?<([A-Za-z_$][\w$]*)>/); + if (named) names.push(named[1]); + } + } + } + return names; +} + +export const OPEN = "⟦"; +export const CLOSE = "⟧"; + +/** The text with every match wrapped in ⟦…⟧. */ +export function highlight(text: string, matches: { index: number; end: number }[]): string { + let out = ""; + let at = 0; + for (const m of matches) { + if (m.index < at) continue; + out += text.slice(at, m.index) + OPEN + text.slice(m.index, m.end) + CLOSE; + at = m.end; + } + return out + text.slice(at); +} + +/** A few characters around one match, on one line, for the side panel. */ +export function context(text: string, m: { index: number; end: number }, around = 40): string { + const start = Math.max(0, m.index - around), end = Math.min(text.length, m.end + around); + return `${start > 0 ? "…" : ""}${ + highlight(text.slice(start, end), [{ index: m.index - start, end: m.end - start }]) + }${end < text.length ? "…" : ""}`; +} + +export function replacePreview(re: RegExp, text: string, replacement: string): string { + re.lastIndex = 0; + const out = text.replace(re, replacement); + re.lastIndex = 0; + return out; +} + +/** `/a\/b/gi` as a JavaScript literal. */ +export const literal = (pattern: string, flags: string) => `/${pattern.replace(/(^|[^\\])\//g, "$1\\/")}/${flags}`; + +/** `new RegExp("\\d+", "g")`. */ +export const constructor = (pattern: string, flags: string) => + `new RegExp(${JSON.stringify(pattern)}${flags ? `, ${JSON.stringify(flags)}` : ""})`; + +// --------------------------------------------------------------------------- +// Explanation + +export interface Token { + text: string; + description: string; + kind: "literal" | "class" | "escape" | "group" | "anchor" | "quantifier" | "alternation" | "backreference"; +} + +const ESCAPES: Record<string, string> = { + d: "a digit (0–9)", + D: "any character except a digit", + w: "a word character (letter, digit or _)", + W: "any character except a word character", + s: "a whitespace character", + S: "any character except whitespace", + b: "a word boundary", + B: "not a word boundary", + n: "a line feed", + r: "a carriage return", + t: "a tab", + v: "a vertical tab", + f: "a form feed", + "0": "a NUL character", +}; + +const CLASS_ESCAPES: Record<string, string> = { + d: "digits", + D: "non-digits", + w: "word characters", + W: "non-word characters", + s: "whitespace", + S: "non-whitespace", + n: "line feed", + r: "carriage return", + t: "tab", +}; + +function describeClass(body: string): string { + const items: string[] = []; + let i = 0; + const one = (): string => { + if (body[i] === "\\") { + const c = body[i + 1]; + i += 2; + return CLASS_ESCAPES[c] ?? `“${c}”`; + } + return `“${body[i++]}”`; + }; + while (i < body.length) { + const start = i; + const a = one(); + if (body[i] === "-" && i + 1 < body.length && body[start] !== "\\") { + i++; + const b = one(); + items.push(`${a.replace(/[“”]/g, "")}–${b.replace(/[“”]/g, "")}`); + } else items.push(a); + } + return items.join(", "); +} + +function quantifier(q: string): string { + const lazy = q.length > 1 && q.endsWith("?") ? ", as few as possible" : ""; + const base = lazy ? q.slice(0, -1) : q; + let text: string; + if (base === "*") text = "zero or more times"; + else if (base === "+") text = "one or more times"; + else if (base === "?") text = "optional"; + else { + const [, n, comma, m] = base.match(/^\{(\d+)(,)?(\d+)?\}$/) ?? []; + if (!comma) text = `exactly ${n} time${n === "1" ? "" : "s"}`; + else if (m === undefined) text = `${n} or more times`; + else text = `between ${n} and ${m} times`; + } + return text + lazy; +} + +/** Splits a pattern into tokens with a short English description each. */ +export function explainTokens(pattern: string, flags = ""): Token[] { + const tokens: Token[] = []; + let group = 0; + const open: string[] = []; + let i = 0; + const push = (t: Token) => { + // A quantifier right after: attach it. + const q = pattern.slice(i).match(/^(?:[*+?]|\{\d+(?:,\d*)?\})\??/); + if (q && t.kind !== "anchor" && t.kind !== "alternation" && !(t.kind === "group" && t.text !== ")")) { + i += q[0].length; + t = { ...t, text: t.text + q[0], description: `${t.description}, ${quantifier(q[0])}` }; + } + const prev = tokens[tokens.length - 1]; + // Merge runs of plain characters into one literal. + if (t.kind === "literal" && prev?.kind === "literal" && t.text.length === 1 && !/[*+?}]$/.test(prev.text)) { + prev.text += t.text; + prev.description = `the text “${prev.text}”`; + } else tokens.push(t); + }; + while (i < pattern.length) { + const c = pattern[i]; + if (c === "\\") { + const rest = pattern.slice(i + 1); + let m: RegExpMatchArray | null; + if ((m = rest.match(/^k<([^>]+)>/))) { + i += 1 + m[0].length; + push({ text: `\\${m[0]}`, description: `the same text as group “${m[1]}”`, kind: "backreference" }); + } else if ((m = rest.match(/^[1-9]\d*/))) { + i += 1 + m[0].length; + push({ text: `\\${m[0]}`, description: `the same text as group ${m[0]}`, kind: "backreference" }); + } else if ((m = rest.match(/^([pP])\{([^}]+)\}/))) { + i += 1 + m[0].length; + const not = m[1] === "P" ? "not " : ""; + push({ text: `\\${m[0]}`, description: `a character ${not}in Unicode category ${m[2]}`, kind: "escape" }); + } else if ((m = rest.match(/^x[0-9a-fA-F]{2}|^u[0-9a-fA-F]{4}|^u\{[0-9a-fA-F]+\}/))) { + i += 1 + m[0].length; + const hex = m[0].replace(/^[xu]\{?|\}$/g, "").toUpperCase().padStart(4, "0"); + push({ text: `\\${m[0]}`, description: `the character U+${hex}`, kind: "escape" }); + } else if ((m = rest.match(/^c[A-Za-z]/))) { + i += 3; + push({ text: `\\${m[0]}`, description: `Ctrl+${m[0][1].toUpperCase()}`, kind: "escape" }); + } else { + const e = rest[0] ?? ""; + i += 2; + if (ESCAPES[e]) { + push({ text: `\\${e}`, description: ESCAPES[e], kind: e === "b" || e === "B" ? "anchor" : "escape" }); + } else push({ text: `\\${e}`, description: `the character “${e}”`, kind: "literal" }); + } + } else if (c === "[") { + let j = i + 1; + if (pattern[j] === "^") j++; + if (pattern[j] === "]") j++; + while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1; + const text = pattern.slice(i, j + 1); + const negated = text.startsWith("[^"); + const body = text.slice(negated ? 2 : 1, -1); + i = j + 1; + push({ + text, + description: body + ? `${negated ? "any character except" : "one of"} ${describeClass(body)}` + : negated + ? "any character" + : "nothing", + kind: "class", + }); + } else if (c === "(") { + const rest = pattern.slice(i); + let m: RegExpMatchArray | null; + if ((m = rest.match(/^\(\?<([A-Za-z_$][\w$]*)>/))) { + group++; + open.push(`group ${group} “${m[1]}”`); + i += m[0].length; + push({ text: m[0], description: `start of capture group ${group}, named “${m[1]}”`, kind: "group" }); + } else if ((m = rest.match(/^\(\?(?:[=!]|<[=!])/))) { + const what = { + "(?=": "followed by", + "(?!": "not followed by", + "(?<=": "preceded by", + "(?<!": "not preceded by", + }[m[0]]!; + open.push(`the “${what}” check`); + i += m[0].length; + push({ text: m[0], description: `only if ${what} (lookaround, not part of the match)`, kind: "group" }); + } else if ((m = rest.match(/^\(\?[a-z-]*:/))) { + open.push("the group"); + i += m[0].length; + push({ + text: m[0], + description: m[0] === "(?:" + ? "start of a group that doesn't capture" + : `start of a group with flags ${m[0].slice(2, -1)}`, + kind: "group", + }); + } else { + group++; + open.push(`group ${group}`); + i++; + push({ text: "(", description: `start of capture group ${group}`, kind: "group" }); + } + } else if (c === ")") { + i++; + push({ text: ")", description: `end of ${open.pop() ?? "a group"}`, kind: "group" }); + } else if (c === "|") { + i++; + push({ text: "|", description: "or", kind: "alternation" }); + } else if (c === "^" || c === "$") { + i++; + const line = flags.includes("m"); + push({ + text: c, + description: c === "^" + ? (line ? "start of a line" : "start of the text") + : line + ? "end of a line" + : "end of the text", + kind: "anchor", + }); + } else if (c === ".") { + i++; + push({ + text: ".", + description: flags.includes("s") ? "any character" : "any character except a line break", + kind: "escape", + }); + } else { + i++; + push({ text: c, description: `the character “${c}”`, kind: "literal" }); + } + } + return tokens; +} + +export const FLAGS: Record<string, string> = { + g: "global: every match, not just the first", + i: "ignore case", + m: "multiline: ^ and $ match at line breaks", + s: "dotAll: . matches line breaks too", + u: "unicode: code points and \\p{…}", + v: "unicodeSets: u with set operations in classes", + y: "sticky: matches only at lastIndex", + d: "indices: start and end of each group", +}; + +// --------------------------------------------------------------------------- +// Library + +export interface Recipe { + id: string; + name: string; + pattern: string; + flags: string; + description: string; + examples: string; + keywords?: string[]; +} + +export const LIBRARY: Recipe[] = [ + { + id: "email", + name: "Email address", + pattern: String.raw`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`, + flags: "g", + description: "A practical address check, not the full RFC 5322 grammar", + examples: "Write to ada@example.com or grace.hopper+navy@mail.example.org, not to @nobody.", + keywords: ["mail", "courriel"], + }, + { + id: "url", + name: "URL (http, https)", + pattern: String.raw`https?:\/\/[\w-]+(?:\.[\w-]+)+(?::\d+)?(?:[/?#][^\s"'<>]*[^\s"'<>.,;:!?)]|[/?#])?`, + flags: "gi", + description: "Web links with an optional port, path, query and fragment", + examples: "See https://deno.com/blog?tag=release#top and http://localhost.example:8080/api, not ftp://old.", + keywords: ["link", "lien", "web"], + }, + { + id: "ipv4", + name: "IPv4 address", + pattern: String.raw`\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b`, + flags: "g", + description: "Four numbers from 0 to 255 separated by dots", + examples: "Gateway 192.168.1.1, DNS 1.1.1.1, invalid 256.1.1.1", + keywords: ["ip", "address", "adresse"], + }, + { + id: "ipv6", + name: "IPv6 address", + pattern: String + .raw`(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?::[0-9A-Fa-f]{1,4}){1,6}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:)`, + flags: "g", + description: "Full and compressed (::) forms, without zone IDs or embedded IPv4", + examples: "2001:0db8:85a3:0000:0000:8a2e:0370:7334, fe80::1 and ::1", + keywords: ["ip", "address", "adresse"], + }, + { + id: "uuid", + name: "UUID", + pattern: String.raw`\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b`, + flags: "gi", + description: "Versions 1 to 8 with the RFC 9562 variant", + examples: "id=f47ac10b-58cc-4372-a567-0e02b2c3d479 and 01890a5d-ac96-774b-bcce-b302099a8057", + keywords: ["guid", "identifier"], + }, + { + id: "date", + name: "Date (ISO 8601)", + pattern: String.raw`\b(?<year>\d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]\d|3[01])\b`, + flags: "g", + description: "YYYY-MM-DD, with year, month and day as named groups", + examples: "Released 2026-10-28, patched 2026-11-02; 2026-13-01 is not a date.", + keywords: ["iso", "yyyy-mm-dd"], + }, + { + id: "datetime", + name: "Date and time (ISO 8601)", + pattern: String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?`, + flags: "g", + description: "Timestamps like 2026-10-28T09:30:00Z or with an offset", + examples: "created_at: 2026-10-28T09:30:00Z, updated_at: 2026-10-28T11:45:12.250+02:00", + keywords: ["timestamp", "iso", "rfc3339"], + }, + { + id: "time", + name: "Time (24-hour)", + pattern: String.raw`\b(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\b`, + flags: "g", + description: "HH:MM or HH:MM:SS", + examples: "Opens 08:30, closes 18:00:00; 24:00 is not valid.", + keywords: ["hour", "heure"], + }, + { + id: "semver", + name: "Semantic version", + pattern: String + .raw`\bv?(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<pre>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?<build>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\b`, + flags: "g", + description: "MAJOR.MINOR.PATCH with optional pre-release and build metadata", + examples: "Upgrade from v1.4.2 to 2.0.0-rc.1+build.42", + keywords: ["version", "semver"], + }, + { + id: "hex", + name: "Hex color", + pattern: String.raw`#(?:[0-9a-fA-F]{3,4}){1,2}\b`, + flags: "g", + description: "#rgb, #rgba, #rrggbb and #rrggbbaa", + examples: "color: #fff; background: #1e1e2e; border: #ff000080;", + keywords: ["colour", "couleur", "css"], + }, + { + id: "mac", + name: "MAC address", + pattern: String.raw`\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b`, + flags: "g", + description: "Six hex pairs separated by colons or dashes", + examples: "eth0 at 3c:52:82:4f:aa:01, wlan0 at 3C-52-82-4F-AA-02", + keywords: ["ethernet", "hardware"], + }, + { + id: "domain", + name: "Domain name", + pattern: String.raw`\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}\b`, + flags: "gi", + description: "Host names with at least one dot", + examples: "Point api.example.com and www.backslash.dev at the new server.", + keywords: ["host", "hostname", "domaine"], + }, + { + id: "phone", + name: "Phone number (E.164)", + pattern: String.raw`\+[1-9]\d{6,14}\b`, + flags: "g", + description: "International format: + and up to 15 digits", + examples: "Call +33612345678 or +14155550123.", + keywords: ["telephone", "téléphone"], + }, + { + id: "number", + name: "Number", + pattern: String.raw`[-+]?\d+(?:[.,]\d+)?`, + flags: "g", + description: "Integers and decimals, with a dot or a comma", + examples: "Total: 42 items, -3.5 °C, 12,75 €", + keywords: ["integer", "decimal", "nombre"], + }, + { + id: "slug", + name: "Slug", + pattern: String.raw`^[a-z0-9]+(?:-[a-z0-9]+)*$`, + flags: "gm", + description: "Lowercase words joined by dashes, one per line", + examples: "hello-world\nHello World\nmy-post-2026", + keywords: ["url", "permalink"], + }, + { + id: "password", + name: "Strong password", + pattern: String.raw`^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{12,}$`, + flags: "gm", + description: "12+ characters with lower and upper case, a digit and a symbol (lookaheads)", + examples: "password123\nCorrect-Horse-42!\nShort1!", + keywords: ["mot de passe", "lookahead"], + }, + { + id: "duplicate", + name: "Repeated word", + pattern: String.raw`\b(\w+)\s+\1\b`, + flags: "gi", + description: "The same word twice in a row (a back-reference)", + examples: "This is is a test of the the regex.", + keywords: ["typo", "doublon"], + }, + { + id: "html", + name: "HTML tag", + pattern: String.raw`<\/?(?<tag>[a-z][a-z0-9-]*)\b[^>]*>`, + flags: "gi", + description: "Opening and closing tags, with the tag name as a group", + examples: '<p class="lead">Hello <strong>world</strong></p>', + keywords: ["xml", "markup"], + }, + { + id: "markdown-link", + name: "Markdown link", + pattern: String.raw`\[(?<label>[^\]]+)\]\((?<url>[^)\s]+)\)`, + flags: "g", + description: "[label](url), with both parts as groups", + examples: "Read the [guide](https://example.com/guide) and the [FAQ](/faq).", + keywords: ["md"], + }, + { + id: "env", + name: "Environment variable line", + pattern: String.raw`^(?<name>[A-Z_][A-Z0-9_]*)=(?<value>.*)$`, + flags: "gm", + description: "NAME=value lines of a .env file", + examples: "DATABASE_URL=postgres://localhost/app\n# comment\nDEBUG=true", + keywords: ["dotenv", ".env"], + }, + { + id: "sha", + name: "Git commit hash", + pattern: String.raw`\b[0-9a-f]{7,40}\b`, + flags: "g", + description: "Short or full SHA-1 hashes", + examples: "Fixed in 3f2a9c1, reverted by 9b8e7d6c5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d", + keywords: ["git", "commit", "sha1"], + }, + { + id: "jwt", + name: "JSON Web Token", + pattern: String.raw`eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+`, + flags: "g", + description: "Header, payload and signature in base64url", + examples: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc123-_xyz", + keywords: ["token", "bearer"], + }, + { + id: "trim", + name: "Leading and trailing spaces", + pattern: String.raw`^\s+|\s+$`, + flags: "gm", + description: "Whitespace at the start or end of each line", + examples: " indented line\ntrailing spaces ", + keywords: ["whitespace", "espace"], + }, +]; + +export const SAMPLE_TEXT = [ + "Ada Lovelace <ada@example.com> released v1.2.0 on 2026-10-28 at 09:30.", + "Server 192.168.1.20 answered https://status.example.com/api?check=1 with #2ecc71.", + "Request f47ac10b-58cc-4372-a567-0e02b2c3d479 took 42 ms.", +].join("\n"); diff --git a/extensions/regex/lib_test.ts b/extensions/regex/lib_test.ts new file mode 100644 index 0000000..e1aa23d --- /dev/null +++ b/extensions/regex/lib_test.ts @@ -0,0 +1,164 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { + closingSlash, + compile, + constructor, + context, + explainTokens, + findMatches, + groupNames, + highlight, + LIBRARY, + literal, + parseQuery, + replacePreview, +} from "./lib.ts"; + +Deno.test("queries: literal, sed-style and bare patterns", () => { + assertEquals(parseQuery("/(\\d+)px/g width: 12px"), { + pattern: "(\\d+)px", + flags: "g", + text: "width: 12px", + literal: true, + }); + // Escaped slashes and slashes inside a class don't close the pattern. + assertEquals(parseQuery("/a\\/b[/]c/gi x")?.pattern, "a\\/b[/]c"); + assertEquals(parseQuery("/abc/")?.text, undefined); + assertEquals(parseQuery("/abc"), { pattern: "abc", flags: "g", literal: true }); + assertEquals(parseQuery("s/(\\w+)@/$1 at /g ada@x.com"), { + pattern: "(\\w+)@", + replacement: "$1 at ", + flags: "g", + text: "ada@x.com", + literal: true, + }); + assertEquals(parseQuery("\\d+ "), { pattern: "\\d+", flags: "g", literal: false }); + assertEquals(parseQuery("/a/g multi\nline"), { pattern: "a", flags: "g", text: "multi\nline", literal: true }); + assertEquals(parseQuery(" "), undefined); + assertThrows(() => parseQuery("/a/g,x"), Error, "Flags go right after"); + assertEquals([closingSlash("a/b", 0), closingSlash("a\\/b", 0), closingSlash("[/]", 0)], [1, -1, -1]); +}); + +Deno.test("compile reports clean errors", () => { + assertThrows(() => compile("(", "g"), Error, "Unterminated group"); + assertThrows(() => compile("a", "gq"), Error, "Unknown flag “q”"); + assertThrows(() => compile("a", "gg"), Error, "Invalid flags"); + assertEquals(compile("a", "gi").flags, "dgi"); +}); + +Deno.test("matches with numbered and named groups", () => { + const re = compile("(?<year>\\d{4})-(\\d{2})(-(\\d{2}))?", "g"); + const { matches, truncated } = findMatches(re, "From 2026-10-28 to 2027-01."); + assertEquals(truncated, false); + assertEquals(matches.map((m) => [m.index, m.end, m.text]), [[5, 15, "2026-10-28"], [19, 26, "2027-01"]]); + assertEquals(matches[0].groups, [ + { number: 1, name: "year", value: "2026", start: 5, end: 9 }, + { number: 2, name: undefined, value: "10", start: 10, end: 12 }, + { number: 3, name: undefined, value: "-28", start: 12, end: 15 }, + { number: 4, name: undefined, value: "28", start: 13, end: 15 }, + ]); + assertEquals(matches[1].groups[3], { + number: 4, + name: undefined, + value: undefined, + start: undefined, + end: undefined, + }); + // Without g: the first match only. + assertEquals(findMatches(compile("\\d", ""), "a1b2").matches.length, 1); + // Empty matches advance instead of looping. + assertEquals(findMatches(compile("x*", "g"), "axxb").matches.map((m) => m.text), ["", "xx", "", ""]); + assertEquals(findMatches(compile("", "gu"), "😀a").matches.map((m) => m.index), [0, 2, 3]); + const many = findMatches(compile(".", "g"), "abcdef", 3); + assertEquals([many.matches.length, many.truncated], [3, true]); +}); + +Deno.test("group names ignore escapes, classes and lookarounds", () => { + assertEquals(groupNames("(a)(?:b)(?<n>c)\\((?=d)[(](?<!e)(f)"), [undefined, "n", undefined]); +}); + +Deno.test("highlighting, context and replacement", () => { + const text = "cat bat rat"; + const { matches } = findMatches(compile("[cr]at", "g"), text); + assertEquals(highlight(text, matches), "⟦cat⟧ bat ⟦rat⟧"); + assertEquals(context("x".repeat(50) + "MATCH" + "y".repeat(50), { index: 50, end: 55 }, 5), "…xxxxx⟦MATCH⟧yyyyy…"); + assertEquals(replacePreview(compile("(\\w+)@(\\w+)", "g"), "ada@home bob@work", "$2:$1"), "home:ada work:bob"); + assertEquals(replacePreview(compile("o", ""), "foo", "0"), "f0o"); + assertEquals(replacePreview(compile("(?<w>\\w+)", "g"), "hi", "<$<w>>"), "<hi>"); +}); + +Deno.test("copy formats", () => { + assertEquals(literal("a/b", "g"), "/a\\/b/g"); + assertEquals(literal("a\\/b", "g"), "/a\\/b/g"); + assertEquals(constructor("\\d+", "g"), 'new RegExp("\\\\d+", "g")'); + assertEquals(constructor("x", ""), 'new RegExp("x")'); +}); + +Deno.test("token explanations", () => { + const tokens = explainTokens("^(?<year>\\d{4})-[^a-z_]+?(?:foo|bar)*\\k<year>\\.$"); + assertEquals(tokens.map((t) => [t.text, t.description]), [ + ["^", "start of the text"], + ["(?<year>", "start of capture group 1, named “year”"], + ["\\d{4}", "a digit (0–9), exactly 4 times"], + [")", "end of group 1 “year”"], + ["-", "the character “-”"], + ["[^a-z_]+?", "any character except a–z, “_”, one or more times, as few as possible"], + ["(?:", "start of a group that doesn't capture"], + ["foo", "the text “foo”"], + ["|", "or"], + ["bar", "the text “bar”"], + [")*", "end of the group, zero or more times"], + ["\\k<year>", "the same text as group “year”"], + ["\\.", "the character “.”"], + ["$", "end of the text"], + ]); + assertEquals(explainTokens("^a$", "m").map((t) => t.description), [ + "start of a line", + "the character “a”", + "end of a line", + ]); + assertEquals(explainTokens("colou?r").map((t) => t.text), ["colo", "u?", "r"]); + assertEquals(explainTokens("(?=x)(?<!y)\\1").map((t) => t.description), [ + "only if followed by (lookaround, not part of the match)", + "the character “x”", + "end of the “followed by” check", + "only if not preceded by (lookaround, not part of the match)", + "the character “y”", + "end of the “not preceded by” check", + "the same text as group 1", + ]); + assertEquals(explainTokens("\\p{L}{2,}\\u00e9\\x41.\\b").map((t) => t.description), [ + "a character in Unicode category L, 2 or more times", + "the character U+00E9", + "the character U+0041", + "any character except a line break", + "a word boundary", + ]); + assertEquals(explainTokens("[\\d.-]{1,3}").map((t) => t.description), [ + "one of digits, “.”, “-”, between 1 and 3 times", + ]); +}); + +Deno.test("every library pattern compiles and matches its examples", () => { + const expected: Record<string, string[]> = { + email: ["ada@example.com", "grace.hopper+navy@mail.example.org"], + url: ["https://deno.com/blog?tag=release#top", "http://localhost.example:8080/api"], + ipv4: ["192.168.1.1", "1.1.1.1"], + ipv6: ["2001:0db8:85a3:0000:0000:8a2e:0370:7334", "fe80::1", "::1"], + date: ["2026-10-28", "2026-11-02"], + semver: ["v1.4.2", "2.0.0-rc.1+build.42"], + hex: ["#fff", "#1e1e2e", "#ff000080"], + duplicate: ["is is", "the the"], + password: ["Correct-Horse-42!"], + }; + for (const r of LIBRARY) { + const { matches } = findMatches(compile(r.pattern, r.flags), r.examples); + if (r.id !== "trim") assertEquals(matches.length > 0, true, r.id); + if (expected[r.id]) assertEquals(matches.map((m) => m.text), expected[r.id], r.id); + } + const semver = findMatches(compile(LIBRARY.find((r) => r.id === "semver")!.pattern, "g"), "2.0.0-rc.1+build.42"); + assertEquals( + semver.matches[0].groups.map((g) => [g.name, g.value]), + [["major", "2"], ["minor", "0"], ["patch", "0"], ["pre", "rc.1"], ["build", "build.42"]], + ); +}); diff --git a/extensions/slack/api.ts b/extensions/slack/api.ts new file mode 100644 index 0000000..d8d4859 --- /dev/null +++ b/extensions/slack/api.ts @@ -0,0 +1,147 @@ +// Calls to the Slack Web API with the user's token, and what the views share. + +import { type Context, toast } from "@backslash/api"; +import { + type Conversation, + type Duration, + encodeParams, + expiryFor, + slackError, + type Status, + type User, + userToken, +} from "./lib.ts"; + +export const API = "https://slack.com/api"; + +export async function slack<T>( + token: string, + method: string, + params: Record<string, unknown> = {}, + signal?: AbortSignal, +): Promise<T> { + let res: Response; + try { + res = await fetch(`${API}/${method}`, { + method: "POST", + signal, + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-www-form-urlencoded" }, + body: encodeParams(params), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Slack is unreachable: ${(e as Error).message}`); + } + if (res.status === 429) { + await res.body?.cancel(); + throw new Error(`Slack is rate limiting: try again in ${res.headers.get("retry-after") ?? "a few"} seconds`); + } + const body = await res.json().catch(() => undefined) as { ok?: boolean; error?: string; needed?: string }; + if (!body) throw new Error(`Slack answered ${res.status}`); + if (!body.ok) throw slackError(body); + return body as T; +} + +/** Follows `response_metadata.next_cursor` up to `max` results. */ +export async function paged<T>( + token: string, + method: string, + key: string, + params: Record<string, unknown>, + max: number, + signal?: AbortSignal, +): Promise<T[]> { + const out: T[] = []; + let cursor = ""; + do { + const page = await slack<Record<string, unknown> & { response_metadata?: { next_cursor?: string } }>( + token, + method, + { ...params, limit: 200, cursor: cursor || undefined }, + signal, + ); + out.push(...((page[key] as T[] | undefined) ?? [])); + cursor = page.response_metadata?.next_cursor ?? ""; + } while (cursor && out.length < max); + return out; +} + +export const tokenOf = (ctx: Pick<Context, "preferences">) => userToken(ctx.preferences); + +export interface Team { + team_id: string; + team: string; + url: string; + user_id: string; + user: string; +} + +export const whoAmI = (token: string, signal?: AbortSignal) => slack<Team>(token, "auth.test", {}, signal); + +export async function getStatus(token: string, signal?: AbortSignal): Promise<Status> { + const { profile } = await slack< + { profile: { status_text?: string; status_emoji?: string; status_expiration?: number } } + >(token, "users.profile.get", {}, signal); + return { + text: profile.status_text ?? "", + emoji: profile.status_emoji ?? "", + expiration: profile.status_expiration ?? 0, + }; +} + +export async function setStatus(token: string, status: Status): Promise<void> { + await slack(token, "users.profile.set", { + profile: { status_text: status.text, status_emoji: status.emoji, status_expiration: status.expiration }, + }); +} + +export const clearStatus = (token: string) => setStatus(token, { text: "", emoji: "", expiration: 0 }); + +export async function setStatusFor( + token: string, + status: { text: string; emoji: string }, + duration: Duration, +): Promise<Status> { + const full = { ...status, expiration: expiryFor(duration) }; + await setStatus(token, full); + return full; +} + +export interface Dnd { + dnd_enabled: boolean; + next_dnd_start_ts?: number; + next_dnd_end_ts?: number; + snooze_enabled?: boolean; + snooze_endtime?: number; + snooze_remaining?: number; +} + +export const getDnd = (token: string, signal?: AbortSignal) => slack<Dnd>(token, "dnd.info", {}, signal); + +export async function snooze(token: string, minutes: number): Promise<number> { + const res = await slack<{ snooze_endtime: number }>(token, "dnd.setSnooze", { num_minutes: minutes }); + return res.snooze_endtime; +} + +export const endSnooze = (token: string) => slack(token, "dnd.endSnooze"); + +export function conversations(token: string, signal?: AbortSignal): Promise<Conversation[]> { + return paged<Conversation>( + token, + "users.conversations", + "channels", + { types: "public_channel,private_channel,mpim,im", exclude_archived: true }, + 1000, + signal, + ); +} + +/** Everyone in the workspace (for names and pictures in direct messages), up to 2000. */ +export async function users(token: string, signal?: AbortSignal): Promise<Map<string, User>> { + const all = await paged<User>(token, "users.list", "members", {}, 2000, signal); + return new Map(all.map((u) => [u.id, u])); +} + +export async function done(message: string) { + await toast(message, { style: "success" }); +} diff --git a/extensions/slack/backslash.toml b/extensions/slack/backslash.toml new file mode 100644 index 0000000..38a810e --- /dev/null +++ b/extensions/slack/backslash.toml @@ -0,0 +1,61 @@ +[extension] +id = "slack" +title = "Slack" +description = "Set your Slack status from presets, pause notifications, jump to channels and search messages" +version = "1.0.0" +author = "Backslash" +icon = "bs-hash" +license = "MIT" + +# The Web API, and the people's pictures in direct messages: Slack hosts them +# on avatars.slack-edge.com, or Gravatar with a default on a.slack-edge.com. +[permissions] +net = ["slack.com", "avatars.slack-edge.com", "secure.gravatar.com", "a.slack-edge.com"] + +[[commands]] +name = "status" +title = "Set Slack Status" +description = "Presets (meeting, commuting, lunch, focus), custom status with expiry, Do Not Disturb" +icon = "bs-smile" +keywords = ["slack", "status", "meeting", "lunch", "focus", "away", "dnd", "do not disturb", "statut", "réunion", "déjeuner", "concentration", "ne pas déranger"] + +[[commands]] +name = "clear-status" +title = "Clear Slack Status" +description = "Removes your status text and emoji" +icon = "bs-x" +mode = "no-view" +keywords = ["slack", "status", "clear", "reset", "effacer", "statut"] + +[[commands]] +name = "pause-notifications" +title = "Pause Slack Notifications" +description = "Do Not Disturb for 30 minutes, 1 or 2 hours, or until tomorrow morning" +icon = "bs-bell-off" +mode = "no-view" +keywords = ["slack", "dnd", "do not disturb", "snooze", "mute", "notifications", "ne pas déranger", "pause", "silence"] +[[commands.arguments]] +name = "duration" +type = "choice" +options = ["30 minutes", "1 hour", "2 hours", "until tomorrow"] +required = true + +[[commands]] +name = "channels" +title = "Slack Channels" +description = "Channels and direct messages you are in; open them in the Slack app" +icon = "bs-hash" +keywords = ["slack", "channels", "dm", "direct messages", "conversations", "chat", "canaux", "messages privés"] + +[[commands]] +name = "search" +title = "Search Slack Messages" +description = "Messages from every channel you can see; supports in:#channel and from:@someone" +icon = "bs-search" +keywords = ["slack", "search", "messages", "find", "chercher", "rechercher"] + +[[preferences]] +name = "token" +title = "User token" +description = "A user token (xoxp-…) of your own Slack app, with the user scopes users.profile:read, users.profile:write, dnd:read, dnd:write, channels:read, groups:read, im:read, mpim:read, users:read and search:read" +type = "secret" diff --git a/extensions/slack/commands/channels.ts b/extensions/slack/commands/channels.ts new file mode 100644 index 0000000..380023e --- /dev/null +++ b/extensions/slack/commands/channels.ts @@ -0,0 +1,136 @@ +import { type Context, copy, Icons, type Item, list, meta, type Metadata, open } from "@backslash/api"; +import { conversations, slack, tokenOf, users as loadUsers, whoAmI } from "../api.ts"; +import { + appUrl, + avatarOf, + cleanText, + type Conversation, + conversationTitle, + guarded, + isUseful, + kindColor, + kindOf, + personName, + sortConversations, + unreadOf, + type User, + webUrl, +} from "../lib.ts"; + +const SECTIONS = { channel: "Channels", private: "Channels", group: "Group Messages", dm: "Direct Messages" }; + +let counts = { channels: 0, dms: 0, unread: 0 }; + +function conversationItem( + c: Conversation, + users: Map<string, User>, + team: { id: string; url: string; me: string }, +): Item { + const kind = kindOf(c); + const title = conversationTitle(c, users, team.me); + const person = kind === "dm" ? users.get(c.user ?? "") : undefined; + const picture = avatarOf(person); + const unread = unreadOf(c); + const topic = cleanText(c.topic?.value?.trim() || c.purpose?.value?.trim() || ""); + const web = webUrl(team.url, c.id); + const lines: Metadata[] = [ + meta.text( + "Type", + { + channel: "Public channel", + private: "Private channel", + group: "Group message", + dm: "Direct message", + }[kind], + { icon: kind === "private" ? Icons.lock : kind === "channel" ? Icons.hash : Icons.person }, + ), + ]; + if (person) { + lines.push(meta.person("With", person.profile?.real_name || personName(person), picture)); + if (person.profile?.title) lines.push(meta.text("Title", person.profile.title)); + } + if (c.num_members) lines.push(meta.text("Members", c.num_members, { icon: Icons.person })); + if (unread !== undefined) { + lines.push(meta.text("Unread", unread, { icon: Icons.inbox, color: unread ? "blue" : undefined })); + } + if (c.is_ext_shared) lines.push(meta.text("Shared", "With another organization", { icon: Icons.globe })); + if (c.created) { + lines.push(meta.text("Created", new Date(c.created * 1000).toLocaleDateString("en", { dateStyle: "medium" }))); + } + lines.push(meta.separator(), meta.link("Link", web.replace(/^https:\/\//, ""), web)); + return { + id: c.id, + title, + subtitle: topic ? topic.split("\n")[0] : undefined, + icon: picture ?? { + source: kind === "private" ? Icons.lock : kind === "channel" ? Icons.hash : Icons.person, + tint: kindColor[kind], + }, + section: SECTIONS[kind], + keywords: [c.name ?? "", person?.name ?? "", person?.real_name ?? ""].filter(Boolean), + accessories: [ + ...(c.is_ext_shared ? [{ icon: Icons.globe, tooltip: "Shared with another organization" }] : []), + ...(unread ? [{ tag: String(unread), color: "blue" as const, tooltip: "Unread" }] : []), + ], + detail: { + markdown: [ + c.topic?.value && `**Topic:** ${cleanText(c.topic.value, { links: true })}`, + c.purpose?.value && cleanText(c.purpose.value, { links: true }), + ] + .filter(Boolean).join("\n\n") || undefined, + metadata: lines, + }, + actions: [ + open(appUrl(team.id, c.id), "Open in Slack", { icon: Icons.hash }), + open(web, "Open in Browser", { icon: Icons.globe }), + copy(web, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(kind === "dm" || kind === "group" ? title : `<#${c.id}>`, "Copy Mention", { section: "Copy" }), + copy(c.id, "Copy Channel ID", { shortcut: "<Ctrl>period", section: "Copy" }), + ], + }; +} + +export default (ctx: Context) => + list({ + placeholder: "Jump to a channel or a person…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "channels", title: "Channels" }, + { value: "dms", title: "Direct Messages" }, + ], + }, + empty: { title: "No conversation here", icon: Icons.hash }, + status: (items) => + items[0]?.id === "setup" ? "Not connected" : [ + `${counts.channels} channel${counts.channels === 1 ? "" : "s"}`, + `${counts.dms} direct message${counts.dms === 1 ? "" : "s"}`, + counts.unread ? `${counts.unread} with unread` : "", + ].filter(Boolean).join(" · "), + items: ({ signal, filter }) => + guarded(async () => { + const token = tokenOf(ctx); + const [me, convos] = await Promise.all([whoAmI(token, signal), conversations(token, signal)]); + const needsPeople = convos.some((c) => c.is_im); + // Without users:read, direct messages show the person's ID. + const people = needsPeople ? await loadUsers(token, signal).catch(() => new Map<string, User>()) : new Map(); + // Members missing from users.list (other workspaces of a shared channel). + const missing = convos.filter((c) => c.is_im && c.user && !people.has(c.user)).slice(0, 15); + await Promise.all(missing.map(async (c) => { + const res = await slack<{ user: User }>(token, "users.info", { user: c.user }, signal).catch(() => undefined); + if (res) people.set(res.user.id, res.user); + })); + const shown = sortConversations(convos.filter((c) => isUseful(c, people)), people); + counts = { + channels: shown.filter((c) => !c.is_im && !c.is_mpim).length, + dms: shown.filter((c) => c.is_im || c.is_mpim).length, + unread: shown.filter((c) => (unreadOf(c) ?? 0) > 0).length, + }; + const team = { id: me.team_id, url: me.url, me: me.user }; + return shown + .filter((c) => + filter === "channels" ? !c.is_im && !c.is_mpim : filter === "dms" ? c.is_im || c.is_mpim : true + ) + .map((c) => conversationItem(c, people, team)); + }), + }); diff --git a/extensions/slack/commands/clear-status.ts b/extensions/slack/commands/clear-status.ts new file mode 100644 index 0000000..df69125 --- /dev/null +++ b/extensions/slack/commands/clear-status.ts @@ -0,0 +1,18 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { clearStatus, getStatus, tokenOf } from "../api.ts"; +import { AuthError } from "../lib.ts"; + +export default run(async (ctx) => { + let token: string; + try { + token = tokenOf(ctx); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Set Slack Status to see how`); + throw e; + } + const before = await getStatus(token); + if (!before.text && !before.emoji) return "No status to clear"; + await clearStatus(token); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return "Slack status cleared"; +}); diff --git a/extensions/slack/commands/pause-notifications.ts b/extensions/slack/commands/pause-notifications.ts new file mode 100644 index 0000000..e40ce80 --- /dev/null +++ b/extensions/slack/commands/pause-notifications.ts @@ -0,0 +1,15 @@ +import { run } from "@backslash/api"; +import { snooze, tokenOf } from "../api.ts"; +import { AuthError, clock, snoozeMinutes } from "../lib.ts"; + +export default run<{ duration: string }>(async (ctx) => { + let token: string; + try { + token = tokenOf(ctx); + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open Set Slack Status to see how`); + throw e; + } + const end = await snooze(token, snoozeMinutes(String(ctx.args.duration ?? "1 hour"))); + return `Slack notifications paused until ${clock(end)}`; +}); diff --git a/extensions/slack/commands/search.ts b/extensions/slack/commands/search.ts new file mode 100644 index 0000000..54549d6 --- /dev/null +++ b/extensions/slack/commands/search.ts @@ -0,0 +1,92 @@ +import { type Context, copy, Icons, type Item, list, meta, open } from "@backslash/api"; +import { slack, tokenOf } from "../api.ts"; +import { appUrl, cleanText, firstLine, guarded, shortDate, tsDate } from "../lib.ts"; + +interface Match { + iid?: string; + ts: string; + text: string; + user?: string; + username?: string; + team?: string; + permalink: string; + channel: { id: string; name: string; is_im?: boolean; is_mpim?: boolean; is_private?: boolean }; +} + +let total = 0; + +function channelLabel(m: Match): string { + if (m.channel.is_im) return "Direct message"; + if (m.channel.is_mpim) return "Group message"; + return `#${m.channel.name}`; +} + +function matchItem(m: Match): Item { + const text = cleanText(m.text); + const when = tsDate(m.ts); + const where = channelLabel(m); + const who = m.username || m.user || "Someone"; + return { + id: m.iid ?? `${m.channel.id}:${m.ts}`, + title: firstLine(text) || "(attachment)", + subtitle: where, + icon: { + source: m.channel.is_im || m.channel.is_mpim ? Icons.person : m.channel.is_private ? Icons.lock : Icons.comment, + tint: "blue", + }, + accessories: [ + { avatar: who, tooltip: who }, + { text: shortDate(when), tooltip: when.toLocaleString("en-GB") }, + ], + detail: { + markdown: cleanText(m.text, { links: true }).replace(/\n/g, " \n") || "_No text: the message has attachments._", + metadata: [ + meta.person("From", who), + meta.text("In", where, { icon: m.channel.is_private ? Icons.lock : Icons.hash }), + meta.text("Sent", when.toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.clock, + }), + meta.separator(), + meta.link("Link", "Open message", m.permalink), + ], + }, + actions: [ + open(m.permalink, "Open Message"), + ...(m.team ? [open(appUrl(m.team, m.channel.id), "Open Channel in Slack", { icon: Icons.hash })] : []), + copy(m.permalink, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(text, "Copy Text", { shortcut: "<Ctrl>period", section: "Copy" }), + ], + }; +} + +export default (ctx: Context) => + list({ + placeholder: "Search messages: words, in:#channel, from:@someone, before:2026-01-01…", + filter: { + options: [ + { value: "score", title: "Best Match" }, + { value: "timestamp", title: "Newest" }, + ], + }, + empty: { + title: "Search Slack", + description: "Type a few words. Slack's modifiers work too: in:#design from:@ada has:link", + icon: Icons.search, + }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : total ? `${items.length} of ${total} messages` : "", + search: (query, { signal, filter }) => + guarded(async () => { + const token = tokenOf(ctx); + total = 0; + if (!query.trim()) return []; + const res = await slack<{ messages: { matches: Match[]; total: number } }>(token, "search.messages", { + query, + count: 40, + sort: filter === "timestamp" ? "timestamp" : "score", + sort_dir: "desc", + highlight: false, + }, signal); + total = res.messages.total; + return res.messages.matches.map(matchItem); + }), + }); diff --git a/extensions/slack/commands/status.ts b/extensions/slack/commands/status.ts new file mode 100644 index 0000000..038f9b6 --- /dev/null +++ b/extensions/slack/commands/status.ts @@ -0,0 +1,255 @@ +import { + type Action, + action, + type Context, + field, + form, + Icons, + type Item, + list, + meta, + pop, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { clearStatus, done, endSnooze, getDnd, getStatus, setStatus, setStatusFor, snooze, tokenOf } from "../api.ts"; +import { + clock, + type Duration, + DURATIONS, + durationTitle, + emojiCode, + emojiOf, + expiryFor, + guarded, + type Preset, + PRESETS, + snoozeMinutes, + type Status, + statusSubtitle, + untilText, +} from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; + +const SNOOZES = ["30 minutes", "1 hour", "2 hours", "until tomorrow"]; + +function customForm(token: string, initial: Partial<Status> & { duration?: Duration } = {}) { + return form({ + title: "Custom Slack status", + submitTitle: "Set Status", + fields: [ + field.text("text", "Status", { placeholder: "What's happening?", value: initial.text ?? "" }), + field.text("emoji", "Emoji", { + placeholder: ":speech_balloon: or a code like palm_tree", + value: initial.emoji ?? ":speech_balloon:", + }), + field.dropdown("duration", "Clear after", DURATIONS, { value: initial.duration ?? "today" }), + field.checkbox("dnd", "Pause notifications as long (up to 1 day)", { value: false }), + ], + async submit(values) { + const text = String(values.text ?? "").trim(); + const emoji = emojiCode(String(values.emoji ?? "")) || (text ? ":speech_balloon:" : ""); + if (!text && !emoji) throw new Error("Type a status or an emoji"); + if (text.length > 100) throw new Error("A status has 100 characters at most"); + const duration = String(values.duration) as Duration; + const status = await setStatusFor(token, { text, emoji }, duration); + if (values.dnd) await pauseAlong(token, status.expiration); + notifyChanged(); + await done(`Status set ${untilText(status.expiration)}`); + return pop(); + }, + }); +} + +/** Do Not Disturb until the status clears, at most a day (Slack's limit for a snooze). */ +async function pauseAlong(token: string, expiration: number) { + const minutes = expiration > 0 ? Math.round((expiration * 1000 - Date.now()) / 60_000) : 24 * 60; + await snooze(token, Math.max(1, Math.min(minutes, 24 * 60))); +} + +async function applyPreset(token: string, preset: Preset, duration: Duration) { + const status = await setStatusFor(token, { text: preset.text, emoji: preset.emoji }, duration); + if (preset.dnd) await pauseAlong(token, status.expiration); + notifyChanged(); + await done(`${preset.title} ${untilText(status.expiration)}${preset.dnd ? " · notifications paused" : ""}`); +} + +function presetItem(token: string, preset: Preset, current: Status): Item { + const active = current.text === preset.text && current.emoji === preset.emoji; + const forDuration = (d: Duration) => + action(d === "never" ? "Don't Clear" : `For ${durationTitle(d)}`, () => applyPreset(token, preset, d), { + icon: Icons.clock, + }); + const expires = expiryFor(preset.duration); + return { + id: `preset:${preset.id}`, + title: preset.title, + subtitle: `${emojiOf(preset.emoji)} ${preset.text}`, + icon: preset.icon, + section: "Presets", + keywords: [preset.text, preset.emoji.replaceAll(":", "")], + accessories: [ + ...(active ? [{ tag: "Current", color: "green" as const }] : []), + ...(preset.dnd ? [{ icon: Icons.bellOff, tooltip: "Also pauses notifications" }] : []), + { text: durationTitle(preset.duration), tooltip: "Clears after" }, + ], + detail: { + markdown: `## ${emojiOf(preset.emoji)} ${preset.text}`, + metadata: [ + meta.text("Emoji", preset.emoji), + meta.text("Clears", expires ? untilText(expires) : "Never", { icon: Icons.clock }), + meta.text("Notifications", preset.dnd ? "Paused as long" : "Unchanged", { + icon: preset.dnd ? Icons.bellOff : Icons.bell, + }), + ], + }, + actions: [ + action(`Set “${preset.title}”`, () => applyPreset(token, preset, preset.duration), { icon: Icons.check }), + submenu("Set For…", DURATIONS.map((d) => forDuration(d.value)), { + icon: Icons.clock, + shortcut: "<Ctrl>d", + }), + action( + "Customize…", + () => customForm(token, { text: preset.text, emoji: preset.emoji, duration: preset.duration }), + { + icon: Icons.settings, + shortcut: "<Ctrl>e", + }, + ), + ], + }; +} + +function currentItem(token: string, status: Status): Item { + if (!status.text && !status.emoji) { + return { + id: "current", + title: "No status", + subtitle: "Pick a preset or write your own", + icon: { source: Icons.smile, tint: "gray" }, + section: "Current", + actions: [action("Custom Status…", () => customForm(token), { icon: Icons.plus })], + }; + } + return { + id: "current", + title: status.text || emojiOf(status.emoji), + subtitle: untilText(status.expiration), + // The user's own status emoji. + icon: emojiOf(status.emoji).startsWith(":") ? { source: Icons.smile, tint: "green" } : emojiOf(status.emoji), + section: "Current", + accessories: [{ tag: "Current", color: "green" }], + detail: { + markdown: `## ${emojiOf(status.emoji)} ${status.text}`, + metadata: [ + meta.text("Emoji", status.emoji || "None"), + meta.text("Clears", status.expiration ? untilText(status.expiration) : "Never", { icon: Icons.clock }), + ], + }, + actions: [ + action("Clear Status", async () => { + await clearStatus(token); + notifyChanged(); + await done("Status cleared"); + }, { icon: Icons.cross }), + action("Edit Status…", () => customForm(token, status), { icon: Icons.settings, shortcut: "<Ctrl>e" }), + ], + }; +} + +function dndItems(token: string, snoozedUntil: number | undefined): Item[] { + const items: Item[] = []; + if (snoozedUntil) { + items.push({ + id: "dnd:active", + title: "Notifications paused", + subtitle: `until ${clock(snoozedUntil)}`, + icon: { source: Icons.bellOff, tint: "purple" }, + section: "Do Not Disturb", + accessories: [{ tag: "On", color: "purple" }], + actions: [ + action("Resume Notifications", async () => { + await endSnooze(token); + notifyChanged(); + await done("Notifications resumed"); + }, { icon: Icons.bell }), + ], + }); + } + for (const choice of SNOOZES) { + items.push({ + id: `dnd:${choice}`, + title: `Pause notifications ${/^until/.test(choice) ? choice : `for ${choice}`}`, + subtitle: /tomorrow/.test(choice) ? "Until 9:00 tomorrow" : undefined, + icon: { source: Icons.bellOff, tint: "gray" }, + section: "Do Not Disturb", + keywords: ["dnd", "do not disturb", "snooze", "mute"], + actions: [ + action("Pause Notifications", async () => { + const end = await snooze(token, snoozeMinutes(choice)); + notifyChanged(); + await done(`Notifications paused until ${clock(end)}`); + }, { icon: Icons.bellOff }), + ], + }); + } + return items; +} + +export default (ctx: Context) => { + const typed = ctx.query.trim(); + return list({ + placeholder: "Pick a status…", + status: (items) => items[0]?.id === "setup" ? "Not connected" : "Enter sets the status · Ctrl+D picks how long", + items: ({ signal }) => + live(() => + guarded(async () => { + const token = tokenOf(ctx); + const [status, dnd] = await Promise.all([ + getStatus(token, signal), + getDnd(token, signal).catch(() => undefined), + ]); + const snoozedUntil = dnd?.snooze_enabled && dnd.snooze_endtime ? dnd.snooze_endtime : undefined; + updateCommandMetadata({ + subtitle: statusSubtitle(status) ?? + (snoozedUntil ? `Notifications paused until ${clock(snoozedUntil)}` : null), + }).catch(() => {}); + const custom: Action = action("Custom Status…", () => customForm(token, typed ? { text: typed } : {}), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + }); + const head: Item[] = typed + ? [{ + id: "typed", + title: `Set status “${typed}”`, + subtitle: "Clears at the end of the day", + icon: { source: Icons.comment, tint: "blue" }, + section: "Current", + actions: [ + action("Set Status", async () => { + await setStatus(token, { text: typed, emoji: ":speech_balloon:", expiration: expiryFor("today") }); + notifyChanged(); + await done("Status set until tonight"); + }, { icon: Icons.check }), + action("Customize…", () => customForm(token, { text: typed }), { icon: Icons.settings }), + ], + }] + : []; + return [ + ...head, + currentItem(token, status), + ...PRESETS.map((p) => presetItem(token, p, status)), + { + id: "custom", + title: "Custom Status…", + subtitle: "Your text, emoji and when it clears", + icon: { source: Icons.plus, tint: "blue" }, + section: "Presets", + actions: [custom], + }, + ...dndItems(token, snoozedUntil), + ]; + }), signal), + }); +}; diff --git a/extensions/slack/lib.ts b/extensions/slack/lib.ts new file mode 100644 index 0000000..b097dec --- /dev/null +++ b/extensions/slack/lib.ts @@ -0,0 +1,490 @@ +// Slack: pure logic (status presets and expiry, mrkdwn cleanup, response mapping). + +import { type Color, Icons, type Item, open } from "@backslash/api"; + +export const APPS_URL = "https://api.slack.com/apps"; + +export const SCOPES = [ + "users.profile:read", + "users.profile:write", + "dnd:read", + "dnd:write", + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "users:read", + "search:read", +]; + +/** The token is missing or Slack refused it: the list shows how to connect. */ +export class AuthError extends Error {} + +export function userToken(prefs: Record<string, unknown>): string { + const token = String(prefs.token ?? "").trim(); + if (!token) throw new AuthError("Connect your Slack account"); + if (/^xox[baer]-/.test(token)) { + throw new AuthError("This is not a user token: paste the “User OAuth Token” (xoxp-…)"); + } + if (!token.startsWith("xoxp-")) throw new AuthError("A Slack user token starts with xoxp-"); + return token; +} + +/** The message of a `{ ok: false }` answer. */ +export function slackError(body: { error?: string; needed?: string }): Error { + const code = body.error ?? "unknown_error"; + switch (code) { + case "not_authed": + case "invalid_auth": + case "token_revoked": + case "token_expired": + case "account_inactive": + return new AuthError("Slack rejected the token"); + case "missing_scope": + return new Error( + `The token lacks the ${ + body.needed ?? "needed" + } scope: add it to your Slack app's User Token Scopes and reinstall it`, + ); + case "ratelimited": + return new Error("Slack is rate limiting: try again in a minute"); + case "profile_status_set_failed_not_valid_emoji": + return new Error("Slack doesn't know this emoji: use a code like :palm_tree:"); + case "too_long": + return new Error("The status is too long (100 characters at most)"); + default: + return new Error(`Slack: ${code.replaceAll("_", " ")}`); + } +} + +/** Form-encoded parameters; objects (a profile) go as JSON, as Slack expects. */ +export function encodeParams(params: Record<string, unknown>): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + body.set(key, typeof value === "object" ? JSON.stringify(value) : String(value)); + } + return body; +} + +// --------------------------------------------------------------------------- +// Emoji + +/** Common shortcodes; anything else (custom emoji) stays as :code:. */ +const EMOJI: Record<string, string> = { + spiral_calendar_pad: "🗓️", + calendar: "📆", + date: "📅", + bus: "🚌", + train: "🚆", + train2: "🚆", + car: "🚗", + red_car: "🚗", + bike: "🚲", + airplane: "✈️", + knife_fork_plate: "🍽️", + fork_and_knife: "🍴", + sandwich: "🥪", + hamburger: "🍔", + pizza: "🍕", + coffee: "☕", + headphones: "🎧", + dart: "🎯", + brain: "🧠", + palm_tree: "🌴", + face_with_thermometer: "🤒", + house: "🏠", + house_with_garden: "🏡", + computer: "💻", + speech_balloon: "💬", + no_bell: "🔕", + zzz: "💤", + red_circle: "🔴", + large_green_circle: "🟢", + white_check_mark: "✅", + eyes: "👀", + wave: "👋", + rocket: "🚀", + books: "📚", + phone: "☎️", + telephone_receiver: "📞", + movie_camera: "🎥", + video_camera: "📹", + hospital: "🏥", + baby: "👶", + sunny: "☀️", + christmas_tree: "🎄", + tada: "🎉", + thinking_face: "🤔", + writing_hand: "✍️", + hammer_and_wrench: "🛠️", +}; + +/** `:palm_tree:` → 🌴 (unknown codes are returned as typed). */ +export function emojiOf(code: string | undefined): string { + const name = (code ?? "").trim().replace(/^:|:$/g, ""); + if (!name) return ""; + return EMOJI[name] ?? `:${name}:`; +} + +/** `palm_tree`, `:palm_tree:` or `🌴` → `:palm_tree:` (or the emoji itself when unknown). */ +export function emojiCode(input: string): string { + const text = input.trim(); + if (!text) return ""; + const known = Object.entries(EMOJI).find(([, glyph]) => glyph === text || glyph.replace("️", "") === text); + if (known) return `:${known[0]}:`; + if (/^:?[a-z0-9_+'-]+:?$/i.test(text)) return `:${text.replace(/^:|:$/g, "").toLowerCase()}:`; + return text; +} + +// --------------------------------------------------------------------------- +// Status + +export interface Status { + text: string; + emoji: string; + /** Unix seconds; 0 = doesn't clear. */ + expiration: number; +} + +export interface Preset { + id: string; + title: string; + text: string; + emoji: string; + duration: Duration; + icon: { source: string; tint: Color }; + /** Also pause notifications for the same time. */ + dnd?: boolean; +} + +export type Duration = "30m" | "1h" | "2h" | "4h" | "today" | "week" | "never"; + +export const DURATIONS: { value: Duration; title: string }[] = [ + { value: "30m", title: "30 minutes" }, + { value: "1h", title: "1 hour" }, + { value: "2h", title: "2 hours" }, + { value: "4h", title: "4 hours" }, + { value: "today", title: "Today" }, + { value: "week", title: "This week" }, + { value: "never", title: "Don't clear" }, +]; + +export const PRESETS: Preset[] = [ + { + id: "meeting", + title: "In a meeting", + text: "In a meeting", + emoji: ":spiral_calendar_pad:", + duration: "1h", + icon: { source: Icons.calendar, tint: "red" }, + }, + { + id: "commuting", + title: "Commuting", + text: "Commuting", + emoji: ":bus:", + duration: "30m", + icon: { source: Icons.arrowRight, tint: "blue" }, + }, + { + id: "lunch", + title: "Lunch", + text: "Out for lunch", + emoji: ":knife_fork_plate:", + duration: "1h", + icon: { source: Icons.coffee, tint: "orange" }, + }, + { + id: "focus", + title: "Focus", + text: "Focusing", + emoji: ":headphones:", + duration: "2h", + dnd: true, + icon: { source: Icons.headphones, tint: "purple" }, + }, + { + id: "remote", + title: "Working remotely", + text: "Working remotely", + emoji: ":house_with_garden:", + duration: "today", + icon: { source: Icons.home, tint: "green" }, + }, + { + id: "sick", + title: "Out sick", + text: "Out sick", + emoji: ":face_with_thermometer:", + duration: "today", + icon: { source: Icons.thermometer, tint: "pink" }, + }, + { + id: "vacation", + title: "Vacationing", + text: "Vacationing", + emoji: ":palm_tree:", + duration: "never", + icon: { source: Icons.sun, tint: "yellow" }, + }, +]; + +/** Unix seconds when a status of this duration clears (0: never). */ +export function expiryFor(duration: Duration, now = new Date()): number { + const minutes: Record<string, number> = { "30m": 30, "1h": 60, "2h": 120, "4h": 240 }; + if (duration in minutes) return Math.floor(now.getTime() / 1000) + minutes[duration] * 60; + if (duration === "never") return 0; + const end = new Date(now); + end.setHours(23, 59, 59, 0); + if (duration === "week") { + // Until Sunday night (today when it's Sunday). + end.setDate(end.getDate() + ((7 - end.getDay()) % 7)); + } + return Math.floor(end.getTime() / 1000); +} + +export function durationTitle(duration: Duration): string { + return DURATIONS.find((d) => d.value === duration)?.title ?? duration; +} + +/** Minutes of Do Not Disturb for a choice; "until tomorrow" ends at 9:00 tomorrow. */ +export function snoozeMinutes(choice: string, now = new Date()): number { + const c = choice.trim().toLowerCase(); + if (/^30/.test(c)) return 30; + if (/^1\s*h|^1 hour|^60/.test(c)) return 60; + if (/^2\s*h|^2 hours|^120/.test(c)) return 120; + if (/tomorrow/.test(c)) { + const morning = new Date(now); + morning.setDate(morning.getDate() + 1); + morning.setHours(9, 0, 0, 0); + return Math.max(1, Math.round((morning.getTime() - now.getTime()) / 60_000)); + } + const n = Number(c.replace(/\s*(min|minutes?)$/, "")); + if (Number.isFinite(n) && n > 0) return Math.round(n); + throw new Error(`Unknown duration “${choice}”: 30 minutes, 1 hour, 2 hours or until tomorrow`); +} + +/** "15:30", "tomorrow 09:00" or "Mon 18:00". */ +export function clock(unix: number, now = new Date()): string { + const d = new Date(unix * 1000); + const time = d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + const day = (x: Date) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`; + if (day(d) === day(now)) return time; + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (day(d) === day(tomorrow)) return `tomorrow ${time}`; + const days = (d.getTime() - now.getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${d.toLocaleDateString("en", { weekday: "short" })} ${time}`; + return `${d.toLocaleDateString("en", { day: "numeric", month: "short" })} ${time}`; +} + +/** "until 15:30", "doesn't clear". */ +export function untilText(expiration: number, now = new Date()): string { + return expiration > 0 ? `until ${clock(expiration, now)}` : "doesn't clear"; +} + +/** The root search subtitle: "🗓️ In a meeting · until 15:30". */ +export function statusSubtitle(status: Status | undefined, now = new Date()): string | null { + if (!status || (!status.text && !status.emoji)) return null; + const head = [emojiOf(status.emoji), status.text].filter(Boolean).join(" "); + return status.expiration > 0 ? `${head} · ${untilText(status.expiration, now)}` : head; +} + +// --------------------------------------------------------------------------- +// Messages + +const ENTITIES: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; + +/** + * Slack's mrkdwn to readable text: `<@U1|ada>` → @ada, `<#C1|general>` → + * #general, `<https://x|label>` → label (a markdown link with `links`). + */ +export function cleanText(text: string, options: { links?: boolean; users?: Map<string, string> } = {}): string { + return text + .replace(/<([^>]+)>/g, (_, inner: string) => { + const [target, label] = inner.split("|", 2); + if (target.startsWith("@")) { + const id = target.slice(1); + return `@${label ?? options.users?.get(id) ?? id}`; + } + if (target.startsWith("#")) return `#${label ?? target.slice(1)}`; + if (target.startsWith("!subteam^")) return `@${label?.replace(/^@/, "") ?? "group"}`; + if (target.startsWith("!")) return `@${(label ?? target.slice(1)).replace(/^@/, "").split("^")[0]}`; + if (target.startsWith("mailto:")) return label ?? target.slice(7); + if (options.links) return `[${label ?? target}](${target})`; + return label ?? target; + }) + .replace(/&(amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] ?? m); +} + +/** The first non-empty line, shortened. */ +export function firstLine(text: string, max = 110): string { + const line = text.split("\n").map((l) => l.trim()).find(Boolean) ?? ""; + return line.length > max ? `${line.slice(0, max).trimEnd()}…` : line; +} + +/** Slack timestamps ("1730123456.000200") as dates. */ +export const tsDate = (ts: string) => new Date(Number(ts.split(".")[0]) * 1000); + +/** "14:05", "yesterday", "3d", "Oct 25", "Oct 2024". */ +export function shortDate(date: Date, now = new Date()): string { + const day = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.round((day(now) - day(date)) / 86_400_000); + if (days <= 0) return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + if (days === 1) return "yesterday"; + if (days < 7) return `${days}d`; + if (date.getFullYear() === now.getFullYear()) { + return date.toLocaleDateString("en", { day: "numeric", month: "short" }); + } + return date.toLocaleDateString("en", { month: "short", year: "numeric" }); +} + +// --------------------------------------------------------------------------- +// Conversations + +export interface Conversation { + id: string; + name?: string; + is_channel?: boolean; + is_group?: boolean; + is_im?: boolean; + is_mpim?: boolean; + is_private?: boolean; + is_archived?: boolean; + is_general?: boolean; + is_ext_shared?: boolean; + is_shared?: boolean; + user?: string; + created?: number; + num_members?: number; + topic?: { value: string }; + purpose?: { value: string }; + unread_count?: number; + unread_count_display?: number; +} + +export interface User { + id: string; + name: string; + real_name?: string; + deleted?: boolean; + is_bot?: boolean; + profile?: { display_name?: string; real_name?: string; image_48?: string; image_72?: string; title?: string }; +} + +export type Kind = "channel" | "private" | "dm" | "group"; + +export function kindOf(c: Conversation): Kind { + if (c.is_im) return "dm"; + if (c.is_mpim) return "group"; + if (c.is_private || c.is_group) return "private"; + return "channel"; +} + +/** Who a person is to show: the display name, else the full name. */ +export function personName(user: User | undefined, fallback = "Someone"): string { + if (!user) return fallback; + return user.profile?.display_name || user.profile?.real_name || user.real_name || user.name || fallback; +} + +/** `mpdm-ada--grace--linus-1` → `ada, grace, linus` (without `me`). */ +export function groupName(name: string | undefined, me?: string): string { + const m = /^mpdm-(.+)-\d+$/.exec(name ?? ""); + if (!m) return name ?? "Group"; + const people = m[1].split("--"); + const others = people.filter((p) => p !== me); + return (others.length ? others : people).join(", "); +} + +export function conversationTitle(c: Conversation, users: Map<string, User>, me?: string): string { + switch (kindOf(c)) { + case "dm": + return personName(users.get(c.user ?? ""), c.user ?? "Direct message"); + case "group": + return groupName(c.name, me); + default: + return `#${c.name ?? c.id}`; + } +} + +/** Pictures only from the hosts in `net`. */ +export function avatarOf(user: User | undefined): string | undefined { + const url = user?.profile?.image_72 ?? user?.profile?.image_48; + if (!url) return undefined; + try { + const host = new URL(url).host; + return ["avatars.slack-edge.com", "secure.gravatar.com", "a.slack-edge.com"].includes(host) ? url : undefined; + } catch { + return undefined; + } +} + +export const appUrl = (team: string, channel: string) => + `slack://channel?team=${encodeURIComponent(team)}&id=${encodeURIComponent(channel)}`; + +/** `https://acme.slack.com/` + `C1` → `https://acme.slack.com/archives/C1`. */ +export function webUrl(teamUrl: string, channel: string): string { + const base = teamUrl.endsWith("/") ? teamUrl : `${teamUrl}/`; + return `${base}archives/${channel}`; +} + +const KIND_ORDER: Kind[] = ["channel", "private", "group", "dm"]; + +/** Channels first, by name; then group and direct messages. */ +export function sortConversations(list: Conversation[], users: Map<string, User>): Conversation[] { + return [...list].sort((a, b) => + KIND_ORDER.indexOf(kindOf(a)) - KIND_ORDER.indexOf(kindOf(b)) || + conversationTitle(a, users).localeCompare(conversationTitle(b, users)) + ); +} + +/** Direct messages with deactivated people or bots are noise in a launcher. */ +export function isUseful(c: Conversation, users: Map<string, User>): boolean { + if (c.is_archived) return false; + if (kindOf(c) !== "dm") return true; + const u = users.get(c.user ?? ""); + return !u?.deleted && (c.user !== "USLACKBOT"); +} + +export function unreadOf(c: Conversation): number | undefined { + const n = c.unread_count_display ?? c.unread_count; + return typeof n === "number" ? n : undefined; +} + +export const kindColor: Record<Kind, Color> = { channel: "gray", private: "orange", group: "purple", dm: "blue" }; + +export function setupItem(error: AuthError): Item { + return { + id: "setup", + title: error.message, + subtitle: "A user token of your own Slack app", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Slack", + "", + "Backslash talks to Slack with a **user token** of an app you create in your workspace:", + "", + "1. Open **api.slack.com/apps** (Enter) → **Create New App** → **From scratch**, and pick your workspace.", + "2. In **OAuth & Permissions → User Token Scopes**, add:", + ...SCOPES.map((s) => ` - \`${s}\``), + "3. **Install to Workspace** (some workspaces need an admin's approval), then copy the", + " **User OAuth Token**: it starts with `xoxp-`.", + "4. Paste it in **Settings → Extensions → Slack → Configure → User token**.", + "", + "The token stays in your system keyring.", + ].join("\n"), + }, + actions: [open(APPS_URL, "Create a Slack App", { icon: Icons.key })], + }; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e)]; + throw e; + } +} diff --git a/extensions/slack/lib_test.ts b/extensions/slack/lib_test.ts new file mode 100644 index 0000000..0937dca --- /dev/null +++ b/extensions/slack/lib_test.ts @@ -0,0 +1,166 @@ +import { assert, assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + AuthError, + cleanText, + clock, + type Conversation, + conversationTitle, + emojiCode, + emojiOf, + encodeParams, + expiryFor, + firstLine, + groupName, + isUseful, + kindOf, + setupItem, + shortDate, + slackError, + snoozeMinutes, + sortConversations, + statusSubtitle, + tsDate, + type User, + userToken, + webUrl, +} from "./lib.ts"; + +// A Tuesday afternoon, local time. +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const unix = (d: Date) => Math.floor(d.getTime() / 1000); + +Deno.test("user token: missing, bot token, user token", () => { + assertThrows(() => userToken({}), AuthError, "Connect"); + assertThrows(() => userToken({ token: "xoxb-1-2-3" }), AuthError, "User OAuth Token"); + assertThrows(() => userToken({ token: "hello" }), AuthError, "xoxp-"); + assertEquals(userToken({ token: " xoxp-123-456-abc \n" }), "xoxp-123-456-abc"); +}); + +Deno.test("Slack errors", () => { + assertInstanceOf(slackError({ error: "invalid_auth" }), AuthError); + assertInstanceOf(slackError({ error: "token_revoked" }), AuthError); + const scope = slackError({ error: "missing_scope", needed: "dnd:write" }); + assert(!(scope instanceof AuthError)); + assertStringIncludes(scope.message, "dnd:write"); + assertEquals(slackError({ error: "channel_not_found" }).message, "Slack: channel not found"); +}); + +Deno.test("parameters are form-encoded, the profile as JSON", () => { + const body = encodeParams({ + profile: { status_text: "Lunch & co", status_emoji: ":sandwich:", status_expiration: 0 }, + cursor: undefined, + limit: 200, + }); + assertEquals(body.get("limit"), "200"); + assertEquals(body.has("cursor"), false); + assertEquals(JSON.parse(body.get("profile")!).status_text, "Lunch & co"); +}); + +Deno.test("emoji shortcodes", () => { + assertEquals(emojiOf(":palm_tree:"), "🌴"); + assertEquals(emojiOf("spiral_calendar_pad"), "🗓️"); + assertEquals(emojiOf(":partyparrot:"), ":partyparrot:"); + assertEquals(emojiOf(""), ""); + assertEquals(emojiCode("palm_tree"), ":palm_tree:"); + assertEquals(emojiCode(":Headphones:"), ":headphones:"); + assertEquals(emojiCode("🌴"), ":palm_tree:"); + assertEquals(emojiCode("🗓"), ":spiral_calendar_pad:"); + assertEquals(emojiCode(""), ""); +}); + +Deno.test("status expiry", () => { + assertEquals(expiryFor("30m", NOW), unix(NOW) + 1800); + assertEquals(expiryFor("2h", NOW), unix(NOW) + 7200); + assertEquals(expiryFor("never", NOW), 0); + assertEquals(expiryFor("today", NOW), unix(new Date(2026, 9, 27, 23, 59, 59))); + // Tuesday → Sunday night. + assertEquals(expiryFor("week", NOW), unix(new Date(2026, 10, 1, 23, 59, 59))); + const sunday = new Date(2026, 10, 1, 10, 0, 0); + assertEquals(expiryFor("week", sunday), unix(new Date(2026, 10, 1, 23, 59, 59))); +}); + +Deno.test("snooze durations", () => { + assertEquals(snoozeMinutes("30 minutes", NOW), 30); + assertEquals(snoozeMinutes("1 hour", NOW), 60); + assertEquals(snoozeMinutes("2 hours", NOW), 120); + // 14:10 → 9:00 tomorrow. + assertEquals(snoozeMinutes("until tomorrow", NOW), 18 * 60 + 50); + assertEquals(snoozeMinutes("45", NOW), 45); + assertThrows(() => snoozeMinutes("forever", NOW), Error, "Unknown duration"); +}); + +Deno.test("clock and subtitle", () => { + assertEquals(clock(unix(new Date(2026, 9, 27, 15, 30)), NOW), "15:30"); + assertEquals(clock(unix(new Date(2026, 9, 28, 9, 0)), NOW), "tomorrow 09:00"); + assertEquals(clock(unix(new Date(2026, 9, 30, 18, 0)), NOW), "Fri 18:00"); + assertEquals( + statusSubtitle({ + text: "In a meeting", + emoji: ":spiral_calendar_pad:", + expiration: unix(new Date(2026, 9, 27, 15, 0)), + }, NOW), + "🗓️ In a meeting · until 15:00", + ); + assertEquals(statusSubtitle({ text: "Vacationing", emoji: ":palm_tree:", expiration: 0 }, NOW), "🌴 Vacationing"); + assertEquals(statusSubtitle({ text: "", emoji: "", expiration: 0 }, NOW), null); +}); + +Deno.test("mrkdwn cleanup", () => { + const raw = "Hey <@U024BE7LH|ada>, see <#C1234|design> and <https://example.com/spec|the spec> & <!here>"; + assertEquals(cleanText(raw), "Hey @ada, see #design and the spec & @here"); + assertEquals( + cleanText("<https://example.com/spec|the spec>", { links: true }), + "[the spec](https://example.com/spec)", + ); + assertEquals(cleanText("<@U1>", { users: new Map([["U1", "grace"]]) }), "@grace"); + assertEquals(cleanText("<mailto:ada@example.com|ada@example.com> 1 < 2"), "ada@example.com 1 < 2"); + assertEquals(cleanText("<!subteam^S123|@oncall> ping"), "@oncall ping"); + assertEquals(firstLine("\n\n First line \nsecond"), "First line"); + assertEquals(firstLine("x".repeat(200), 10), "xxxxxxxxxx…"); +}); + +Deno.test("dates of messages", () => { + assertEquals(tsDate("1730123456.000200").getTime(), 1730123456000); + assertEquals(shortDate(new Date(2026, 9, 27, 9, 5), NOW), "09:05"); + assertEquals(shortDate(new Date(2026, 9, 26, 23, 0), NOW), "yesterday"); + assertEquals(shortDate(new Date(2026, 9, 24, 12, 0), NOW), "3d"); + assertStringIncludes(shortDate(new Date(2026, 8, 2), NOW), "Sep 2"); + assertStringIncludes(shortDate(new Date(2024, 2, 2), NOW), "2024"); +}); + +const users = new Map<string, User>([ + ["U1", { id: "U1", name: "ada", profile: { display_name: "", real_name: "Ada Lovelace" } }], + ["U2", { id: "U2", name: "old", deleted: true, profile: { real_name: "Old Account" } }], +]); + +const convos: Conversation[] = [ + { id: "D1", is_im: true, user: "U1" }, + { id: "C2", name: "random", is_channel: true }, + { id: "G1", name: "mpdm-ada--grace--linus-1", is_mpim: true, is_private: true }, + { id: "C1", name: "general", is_channel: true, is_general: true }, + { id: "C3", name: "secret-project", is_channel: true, is_private: true }, + { id: "D2", is_im: true, user: "U2" }, + { id: "D3", is_im: true, user: "USLACKBOT" }, +]; + +Deno.test("conversations: kinds, titles, order", () => { + assertEquals(kindOf(convos[0]), "dm"); + assertEquals(kindOf(convos[2]), "group"); + assertEquals(kindOf(convos[4]), "private"); + assertEquals(conversationTitle(convos[0], users), "Ada Lovelace"); + assertEquals(conversationTitle(convos[1], users), "#random"); + assertEquals(groupName("mpdm-ada--grace--linus-1"), "ada, grace, linus"); + assertEquals(groupName("mpdm-steven--ada-1", "steven"), "ada"); + const useful = convos.filter((c) => isUseful(c, users)); + assertEquals(useful.map((c) => c.id), ["D1", "C2", "G1", "C1", "C3"]); + assertEquals(sortConversations(useful, users).map((c) => c.id), ["C1", "C2", "C3", "G1", "D1"]); + assertEquals(webUrl("https://acme.slack.com/", "C1"), "https://acme.slack.com/archives/C1"); + assertEquals(webUrl("https://acme.slack.com", "C1"), "https://acme.slack.com/archives/C1"); +}); + +Deno.test("setup row explains the scopes and links to the apps page", () => { + const item = setupItem(new AuthError("Connect your Slack account")); + assertEquals(item.id, "setup"); + assertStringIncludes(item.detail!.markdown!, "search:read"); + assertStringIncludes(item.detail!.markdown!, "xoxp-"); +}); diff --git a/extensions/slack/live.ts b/extensions/slack/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/slack/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live<T>(load: () => Promise<T>, signal: AbortSignal): AsyncGenerator<T> { + while (!signal.aborted) { + yield await load(); + await new Promise<void>((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/startup-apps/backslash.toml b/extensions/startup-apps/backslash.toml new file mode 100644 index 0000000..f1d5b0c --- /dev/null +++ b/extensions/startup-apps/backslash.toml @@ -0,0 +1,40 @@ +[extension] +id = "startup-apps" +title = "Startup Apps" +description = "See and change what starts when you log in: autostart apps, systemd user services and Hyprland" +version = "1.0.0" +author = "Backslash" +icon = "bs-rocket" +license = "MIT" + +# Autostart entries are .desktop files in ~/.config/autostart (yours) and +# /etc/xdg/autostart (installed by packages); turning a system entry off writes +# a copy with Hidden=true in your folder, as GNOME Tweaks does. systemctl +# --user lists and toggles your systemd services. ~/.config/hypr is only read, +# for Hyprland's exec-once lines. +[permissions] +run = ["systemctl"] +read = ["~/.config/autostart", "/etc/xdg/autostart", "~/.config/hypr"] +write = ["~/.config/autostart"] +env = ["HOME", "XDG_CURRENT_DESKTOP"] + +[[commands]] +name = "startup-apps" +title = "Startup Apps" +description = "Apps and services started at login; turn them on or off, add your own" +icon = "bs-rocket" +keywords = [ + "startup", + "autostart", + "login", + "boot", + "démarrage", + "démarrage automatique", + "connexion", + "session", + "systemd", + "services", + "exec-once", + "hyprland", + "tweaks", +] diff --git a/extensions/startup-apps/commands/startup-apps.ts b/extensions/startup-apps/commands/startup-apps.ts new file mode 100644 index 0000000..74d298f --- /dev/null +++ b/extensions/startup-apps/commands/startup-apps.ts @@ -0,0 +1,409 @@ +import { + type Action, + action, + copy, + exec, + field, + form, + Icons, + type Item, + list, + meta, + type Metadata, + open, + pop, + storage, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Autostart, + type DesktopFile, + desktopFileName, + type ExecOnce, + mergeAutostart, + newDesktopFile, + parseHyprland, + parseShow, + parseUnitFiles, + programName, + resolveSource, + tilde, + toggledText, + type UnitInfo, +} from "../lib.ts"; + +const HOME = Deno.env.get("HOME") ?? ""; +const DESKTOP = Deno.env.get("XDG_CURRENT_DESKTOP") ?? ""; +const USER_DIR = `${HOME}/.config/autostart`; +const SYSTEM_DIR = "/etc/xdg/autostart"; +const HYPR_DIR = `${HOME}/.config/hypr`; + +// --- Loading ------------------------------------------------------------------- + +async function readDir(dir: string): Promise<DesktopFile[]> { + const files: DesktopFile[] = []; + try { + for await (const e of Deno.readDir(dir)) { + if (!e.name.endsWith(".desktop") || e.isDirectory) continue; + const path = `${dir}/${e.name}`; + const text = await Deno.readTextFile(path).catch(() => undefined); + if (text !== undefined) files.push({ path, text }); + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + return files; +} + +interface Unit extends UnitInfo { + enabled: boolean; + preset?: string; +} + +const DISABLED_KEY = "disabled-units"; + +/** User units enabled at login, plus those turned off here (so they can be turned back on). */ +async function loadUnits(): Promise<Unit[] | undefined> { + let out: string; + try { + out = await exec("systemctl", [ + "--user", + "list-unit-files", + "--state=enabled", + "--type=service", + "--no-legend", + "--plain", + "--no-pager", + ]); + } catch { + return undefined; // No systemd, or no user manager. + } + const files = parseUnitFiles(out); + const disabledHere = (await storage.get<string[]>(DISABLED_KEY).catch(() => undefined)) ?? []; + const names = [...new Set([...files.map((f) => f.unit), ...disabledHere])]; + if (names.length === 0) return []; + const show = await exec("systemctl", [ + "--user", + "show", + "--property=Id,Description,ActiveState,SubState,FragmentPath,WantedBy,ExecStart,UnitFileState", + "--no-pager", + "--", + ...names, + ]).catch(() => undefined); + const infos = new Map(parseShow(show ?? "").map((i) => [i.id, i])); + const enabled = new Set(files.map((f) => f.unit)); + // A unit disabled here and later re-enabled elsewhere, or removed, needs no reminder. + const stillDisabled = show === undefined + ? disabledHere.filter((u) => !enabled.has(u)) + : disabledHere.filter((u) => !enabled.has(u) && infos.get(u)?.fragmentPath); + if (stillDisabled.length !== disabledHere.length) storage.set(DISABLED_KEY, stillDisabled).catch(() => {}); + return [...enabled, ...stillDisabled].map((unit) => ({ + ...(infos.get(unit) ?? { id: unit, wantedBy: [] }), + id: unit, + enabled: enabled.has(unit), + preset: files.find((f) => f.unit === unit)?.preset, + })); +} + +async function loadHyprland(): Promise<{ execs: ExecOnce[]; config?: string }> { + const main = `${HYPR_DIR}/hyprland.conf`; + const execs: ExecOnce[] = []; + const seen = new Set<string>(); + const visit = async (file: string, depth: number) => { + if (seen.has(file) || depth > 5 || !file.startsWith(HYPR_DIR + "/")) return; + seen.add(file); + const text = await Deno.readTextFile(file).catch(() => undefined); + if (text === undefined) return; + const parsed = parseHyprland(text, file); + execs.push(...parsed.execs); + for (const s of parsed.sources) await visit(resolveSource(s, file, HOME), depth + 1); + }; + await visit(main, 0); + return { execs, config: seen.size ? main : undefined }; +} + +// --- Changes ------------------------------------------------------------------- + +const changes = new EventTarget(); +const changed = () => changes.dispatchEvent(new Event("change")); + +async function* live<T>(load: () => Promise<T>, signal: AbortSignal): AsyncGenerator<T> { + while (!signal.aborted) { + yield await load(); + await new Promise<void>((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} + +async function setAutostart(a: Autostart, on: boolean) { + const userPath = `${USER_DIR}/${a.id}`; + const userText = a.origin === "system" ? undefined : await Deno.readTextFile(userPath); + const systemPath = a.origin === "system" ? a.path : a.systemPath; + const systemText = systemPath ? await Deno.readTextFile(systemPath) : undefined; + await Deno.mkdir(USER_DIR, { recursive: true }); + await Deno.writeTextFile(userPath, toggledText(userText, systemText, on)); +} + +async function setUnit(u: Unit, on: boolean) { + await exec("systemctl", ["--user", on ? "enable" : "disable", "--", u.id]).catch((err) => { + throw new Error(String(err?.message ?? err).replace(/^systemctl exited with \d+: /, "")); + }); + const list = (await storage.get<string[]>(DISABLED_KEY).catch(() => undefined)) ?? []; + await storage.set(DISABLED_KEY, on ? list.filter((x) => x !== u.id) : [...new Set([...list, u.id])]); +} + +const addForm = () => + form({ + title: "Add Startup App", + submitTitle: "Add", + fields: [ + field.text("name", "Name", { required: true, placeholder: "Syncthing" }), + field.text("command", "Command", { required: true, placeholder: "syncthing serve --no-browser" }), + field.text("comment", "Description", { placeholder: "Optional" }), + ], + async submit(values) { + const name = String(values.name).trim(); + const command = String(values.command).trim(); + if (!name || !command) throw new Error("A name and a command are needed"); + await Deno.mkdir(USER_DIR, { recursive: true }); + const taken = (await readDir(USER_DIR)).map((f) => f.path.split("/").pop()!); + const file = desktopFileName(name, taken); + await Deno.writeTextFile(`${USER_DIR}/${file}`, newDesktopFile(name, command, String(values.comment ?? ""))); + await toast(`${name} starts at login`, { style: "success" }); + changed(); + return pop(); + }, + }); + +// --- Items --------------------------------------------------------------------- + +const ORIGIN: Record<Autostart["origin"], string> = { + user: "Added by you", + system: "Installed by the system", + override: "System entry, changed by you", +}; + +function autostartItem(a: Autostart): Item { + const toggle = action(a.enabled ? "Disable at Login" : "Enable at Login", async () => { + await setAutostart(a, !a.enabled); + await toast(`${a.name} ${a.enabled ? "won't start" : "starts"} at login`, { style: "success" }); + changed(); + }, { icon: a.enabled ? Icons.pause : Icons.play, shortcut: "<Ctrl>t" }); + const actions: Action[] = [ + toggle, + open(a.path, "Open Desktop File", { icon: Icons.file, shortcut: "<Ctrl>o" }), + ...(a.systemPath && a.systemPath !== a.path ? [open(a.systemPath, "Open System File", { icon: Icons.file })] : []), + open(USER_DIR, "Open Autostart Folder", { icon: Icons.folder }), + copy(a.exec, "Copy Command", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(a.path, "Copy File Path", { section: "Copy" }), + ]; + if (a.origin === "user") { + actions.push(action("Remove", async () => { + await Deno.remove(a.path); + await toast(`${a.name} removed`, { style: "success" }); + changed(); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete ${a.id} from ~/.config/autostart?`, + shortcut: "<Ctrl>BackSpace", + section: "Danger", + })); + } else if (a.origin === "override") { + actions.push(action("Restore System Default", async () => { + await Deno.remove(a.path); + await toast(`${a.name} restored`, { style: "success" }); + changed(); + }, { + icon: Icons.history, + destructive: true, + confirm: `Delete your copy of ${a.id} and use the system's again?`, + section: "Danger", + })); + } + const metadata: Metadata[] = [ + meta.text("Status", a.enabled ? "Starts at login" : "Disabled", { + icon: a.enabled ? Icons.done : Icons.canceled, + color: a.enabled ? "green" : "gray", + }), + meta.text("Program", programName(a.exec) || "–", { icon: Icons.terminal }), + ...(a.delay ? [meta.text("Delay", `${a.delay} s`, { icon: Icons.timer })] : []), + ...(a.entry.OnlyShowIn ? [meta.tags("Only in", a.entry.OnlyShowIn.split(";").filter(Boolean))] : []), + ...(a.entry.NotShowIn ? [meta.tags("Not in", a.entry.NotShowIn.split(";").filter(Boolean))] : []), + meta.separator(), + meta.text("Origin", ORIGIN[a.origin], { icon: a.origin === "system" ? Icons.server : Icons.person }), + meta.text("File", tilde(a.path, HOME), { icon: Icons.file }), + ...(a.systemPath && a.systemPath !== a.path ? [meta.text("System file", a.systemPath)] : []), + ]; + return { + id: `xdg:${a.id}`, + title: a.name, + subtitle: a.comment ?? programName(a.exec), + icon: { source: Icons.rocket, tint: a.enabled ? (a.otherDesktop ? "gray" : "green") : "gray" }, + section: "Autostart Apps", + keywords: [a.id, a.exec, programName(a.exec), a.enabled ? "enabled" : "disabled"], + accessories: [ + ...(a.otherDesktop && a.enabled ? [{ tag: "Other desktops", color: "gray" as const }] : []), + ...(a.origin !== "system" ? [{ icon: Icons.person, tooltip: ORIGIN[a.origin] }] : []), + a.enabled ? { tag: "On", color: "green" as const } : { tag: "Off", color: "gray" as const }, + ], + detail: { + markdown: [ + a.comment ?? "", + a.otherDesktop + ? `> Not started in this desktop (${DESKTOP || "unknown"}): its OnlyShowIn / NotShowIn rule leaves it out.` + : "", + "```\n" + (a.exec || "(no command)") + "\n```", + ].filter(Boolean).join("\n\n"), + metadata, + }, + actions, + }; +} + +function unitItem(u: Unit): Item { + const running = u.activeState === "active"; + const failed = u.activeState === "failed"; + const kind = u.id.split(".").pop() ?? "service"; + const svc = (verb: string, done: string) => + action(`${verb[0].toUpperCase()}${verb.slice(1)} Now`, async () => { + await exec("systemctl", ["--user", verb, "--", u.id]); + await toast(`${u.id} ${done}`, { style: "success" }); + changed(); + }, { + icon: verb === "stop" ? Icons.stop : verb === "start" ? Icons.play : Icons.refresh, + section: "Service", + ...(verb === "stop" ? { confirm: `Stop ${u.id} now?` } : {}), + }); + return { + id: `unit:${u.id}`, + title: u.description && u.description !== u.id ? u.description : u.id, + subtitle: u.description && u.description !== u.id ? u.id : undefined, + icon: { source: Icons.server, tint: !u.enabled ? "gray" : failed ? "red" : running ? "blue" : "gray" }, + section: "Systemd User Services", + keywords: [u.id, kind, "systemd", u.execStart ?? "", u.enabled ? "enabled" : "disabled"], + accessories: [ + ...(failed ? [{ tag: "Failed", color: "red" as const }] : running ? [{ text: "running" }] : []), + u.enabled ? { tag: "On", color: "green" as const } : { tag: "Off", color: "gray" as const }, + ], + detail: { + markdown: u.execStart ? "```\n" + u.execStart + "\n```" : undefined, + metadata: [ + meta.text("Unit", u.id, { icon: Icons.server }), + meta.text("At login", u.enabled ? "Enabled" : "Disabled", { + icon: u.enabled ? Icons.done : Icons.canceled, + color: u.enabled ? "green" : "gray", + }), + meta.text("State", [u.activeState, u.subState].filter(Boolean).join(" · ") || "unknown", { + color: failed ? "red" : running ? "green" : undefined, + }), + ...(u.wantedBy.length ? [meta.tags("Wanted by", u.wantedBy)] : []), + ...(u.preset ? [meta.text("Vendor preset", u.preset)] : []), + meta.separator(), + ...(u.fragmentPath ? [meta.text("File", tilde(u.fragmentPath, HOME), { icon: Icons.file })] : []), + ], + }, + actions: [ + action(u.enabled ? "Disable at Login" : "Enable at Login", async () => { + await setUnit(u, !u.enabled); + await toast(`${u.id} ${u.enabled ? "disabled" : "enabled"}`, { style: "success" }); + changed(); + }, { icon: u.enabled ? Icons.pause : Icons.play, shortcut: "<Ctrl>t" }), + ...(u.fragmentPath ? [open(u.fragmentPath, "Open Unit File", { icon: Icons.file, shortcut: "<Ctrl>o" })] : []), + running ? svc("stop", "stopped") : svc("start", "started"), + ...(running ? [svc("restart", "restarted")] : []), + copy(u.id, "Copy Unit Name", { section: "Copy" }), + copy(`systemctl --user status ${u.id}`, "Copy Status Command", { section: "Copy" }), + ], + }; +} + +function hyprItem(e: ExecOnce, config: string): Item { + const where = `${tilde(e.file, HOME)}:${e.line}`; + return { + id: `hypr:${e.file}:${e.line}`, + title: programName(e.command) || e.command, + subtitle: e.command !== programName(e.command) ? e.command : undefined, + icon: { source: Icons.terminal, tint: "teal" }, + section: "Hyprland", + keywords: [e.command, "hyprland", e.keyword], + accessories: [{ text: where.split("/").pop(), tooltip: where }, { tag: e.keyword, color: "teal" }], + detail: { + markdown: [ + "```", + e.command, + "```", + "", + e.keyword === "exec-once" + ? "Started once when Hyprland starts. Edit the line in your config to change it." + : "Started at login and again each time the config is reloaded.", + ].join("\n"), + metadata: [ + meta.text("Program", programName(e.command), { icon: Icons.terminal }), + meta.text("Keyword", e.keyword), + meta.text("Defined in", where, { icon: Icons.file }), + meta.text("Status", "Read-only here", { icon: Icons.lock, color: "gray" }), + ], + }, + actions: [ + open(e.file, "Open Config File", { icon: Icons.file }), + ...(e.file !== config ? [open(config, "Open hyprland.conf", { icon: Icons.file })] : []), + copy(e.command, "Copy Command", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(where, "Copy Location", { section: "Copy" }), + ], + }; +} + +let counts = { shown: 0, enabled: 0 }; + +const addAction = action("Add Startup App…", () => addForm(), { icon: Icons.plus, shortcut: "<Ctrl>n" }); + +export default list({ + placeholder: "Filter apps that start at login…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "enabled", title: "Enabled" }, + { value: "disabled", title: "Disabled" }, + ], + }, + empty: { + title: "Nothing starts at login", + description: "Press Ctrl+N to add an app that starts when you log in", + icon: Icons.rocket, + }, + actions: [addAction, open(USER_DIR, "Open Autostart Folder", { icon: Icons.folder })], + status: (items) => + `${counts.enabled} at login${items.length !== counts.shown ? ` · ${items.length} of ${counts.shown} shown` : ""}`, + items: ({ filter, signal }) => + live(async () => { + const [user, system, units, hypr] = await Promise.all([ + readDir(USER_DIR), + readDir(SYSTEM_DIR), + loadUnits(), + loadHyprland(), + ]); + const autostart = mergeAutostart(user, system, DESKTOP); + const on = (a: { enabled: boolean }) => filter !== "enabled" || a.enabled; + const off = (a: { enabled: boolean }) => filter !== "disabled" || !a.enabled; + const keep = (a: { enabled: boolean }) => on(a) && off(a); + const enabled = autostart.filter((a) => a.enabled && !a.otherDesktop).length + + (units ?? []).filter((u) => u.enabled).length + hypr.execs.length; + updateCommandMetadata({ subtitle: `${enabled} at login` }).catch(() => {}); + const items = [ + ...autostart.filter(keep).map(autostartItem).map((i) => ({ ...i, actions: [...i.actions!, addAction] })), + ...(units ?? []).filter(keep).map(unitItem), + ...(filter === "disabled" ? [] : hypr.execs.map((e) => hyprItem(e, hypr.config!))), + ]; + counts = { shown: items.length, enabled }; + return items; + }, signal), +}); diff --git a/extensions/startup-apps/lib.ts b/extensions/startup-apps/lib.ts new file mode 100644 index 0000000..1cc429f --- /dev/null +++ b/extensions/startup-apps/lib.ts @@ -0,0 +1,293 @@ +// Pure helpers for Startup Apps: XDG autostart files, systemd user units and Hyprland's exec-once. + +/** The keys of a .desktop file's [Desktop Entry] group (localized keys left out). */ +export type DesktopEntry = Record<string, string>; + +export function parseDesktop(text: string): DesktopEntry { + const out: DesktopEntry = {}; + let inEntry = false; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + if (line.startsWith("[")) { + inEntry = line === "[Desktop Entry]"; + continue; + } + if (!inEntry) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (key.includes("[")) continue; + out[key] = line.slice(eq + 1).trim(); + } + return out; +} + +/** + * Sets (or with `undefined`, removes) a key of the [Desktop Entry] group, + * keeping the rest of the file as it is. + */ +export function setDesktopKey(text: string, key: string, value: string | undefined): string { + const lines = text.trim() ? text.replace(/\n+$/, "").split("\n") : []; + let start = lines.findIndex((l) => l.trim() === "[Desktop Entry]"); + if (start < 0) { + lines.unshift("[Desktop Entry]"); + start = 0; + } + let end = lines.findIndex((l, i) => i > start && l.trim().startsWith("[")); + if (end < 0) end = lines.length; + const at = lines.findIndex((l, i) => i > start && i < end && l.split("=")[0].trim() === key); + if (value === undefined) { + if (at >= 0) lines.splice(at, 1); + } else if (at >= 0) { + lines[at] = `${key}=${value}`; + } else { + // After the last key of the group, before blank lines that separate the next one. + let insert = end; + while (insert > start + 1 && lines[insert - 1].trim() === "") insert--; + lines.splice(insert, 0, `${key}=${value}`); + } + return lines.join("\n") + "\n"; +} + +const isTrue = (v?: string) => v?.toLowerCase() === "true"; +const isFalse = (v?: string) => v?.toLowerCase() === "false"; + +/** Whether the desktop starts the entry: `Hidden=true` and `X-GNOME-Autostart-enabled=false` turn it off. */ +export function isEnabled(e: DesktopEntry): boolean { + return !isTrue(e.Hidden) && !isFalse(e["X-GNOME-Autostart-enabled"]); +} + +/** Whether OnlyShowIn / NotShowIn let the entry start in this desktop (`XDG_CURRENT_DESKTOP`, colon separated). */ +export function showsIn(e: DesktopEntry, currentDesktop: string): boolean { + const current = currentDesktop.split(":").map((d) => d.trim().toLowerCase()).filter(Boolean); + const list = (v?: string) => (v ?? "").split(";").map((d) => d.trim().toLowerCase()).filter(Boolean); + const only = list(e.OnlyShowIn); + const not = list(e.NotShowIn); + if (current.length === 0) return true; + if (only.length && !only.some((d) => current.includes(d))) return false; + if (not.some((d) => current.includes(d))) return false; + return true; +} + +/** Exec without field codes (%u, %F…): what actually runs. */ +export function cleanExec(exec: string): string { + return exec.replace(/\s+%[fFuUdDnNickvm]/g, "").replace(/%%/g, "%").trim(); +} + +/** The program of a command line: `env FOO=1 /usr/bin/app --x` → `app`. */ +export function programName(exec: string): string { + const words = cleanExec(exec).match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + let i = 0; + if (words[i] === "env") { + i++; + while (words[i] && (/^\w+=/.test(words[i]) || words[i].startsWith("-"))) i++; + } + while (words[i] && /^\w+=/.test(words[i])) i++; + const prog = (words[i] ?? "").replace(/^["']|["']$/g, ""); + return prog.split("/").pop() ?? prog; +} + +export type Origin = "user" | "system" | "override"; + +/** One XDG autostart entry, after the user's folder has overridden the system's. */ +export interface Autostart { + /** The file name, e.g. `nm-applet.desktop`; the same name in the user folder overrides the system one. */ + id: string; + name: string; + comment?: string; + exec: string; + enabled: boolean; + /** `user`: only in ~/.config/autostart; `system`: only in /etc/xdg/autostart; `override`: the user's copy of a system entry. */ + origin: Origin; + /** The file that decides (the user's when there is one). */ + path: string; + systemPath?: string; + /** OnlyShowIn / NotShowIn keep it from starting here. */ + otherDesktop: boolean; + delay?: number; + terminal: boolean; + entry: DesktopEntry; +} + +export interface DesktopFile { + path: string; + text: string; +} + +/** + * Merges the user's autostart folder over the system's, as session managers do: + * a user file with the same name replaces the system one. + */ +export function mergeAutostart(user: DesktopFile[], system: DesktopFile[], currentDesktop = ""): Autostart[] { + const name = (p: string) => p.split("/").pop() ?? p; + const sys = new Map(system.map((f) => [name(f.path), f])); + const out = new Map<string, Autostart>(); + const add = (file: DesktopFile, origin: Origin, base?: DesktopFile) => { + const own = parseDesktop(file.text); + // An override may hold little more than Hidden=true: show the system entry's name and command then. + const e = base ? { ...parseDesktop(base.text), ...own } : own; + const id = name(file.path); + const delay = Number(e["X-GNOME-Autostart-Delay"]); + out.set(id, { + id, + name: e.Name || id.replace(/\.desktop$/, ""), + comment: e.Comment || e.GenericName || undefined, + exec: cleanExec(e.Exec ?? ""), + enabled: isEnabled(own), + origin, + path: file.path, + systemPath: base?.path, + otherDesktop: !showsIn(e, currentDesktop), + delay: Number.isFinite(delay) && delay > 0 ? delay : undefined, + terminal: isTrue(e.Terminal), + entry: e, + }); + }; + for (const f of system) add(f, "system"); + for (const f of user) { + const base = sys.get(name(f.path)); + add(f, base ? "override" : "user", base); + } + return [...out.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + +/** + * The user's file after turning an entry on or off, as GNOME Tweaks does: the + * user's own file is edited, a system entry gets a copy in the user's folder. + */ +export function toggledText(userText: string | undefined, systemText: string | undefined, on: boolean): string { + let text = userText ?? systemText ?? "[Desktop Entry]\n"; + text = setDesktopKey(text, "Hidden", on ? "false" : "true"); + if (!on) return setDesktopKey(text, "X-GNOME-Autostart-enabled", "false"); + return parseDesktop(text)["X-GNOME-Autostart-enabled"] === undefined + ? text + : setDesktopKey(text, "X-GNOME-Autostart-enabled", "true"); +} + +/** A new autostart entry. */ +export function newDesktopFile(name: string, command: string, comment = ""): string { + const lines = [ + "[Desktop Entry]", + "Type=Application", + `Name=${name.replace(/\n/g, " ").trim()}`, + ...(comment.trim() ? [`Comment=${comment.replace(/\n/g, " ").trim()}`] : []), + `Exec=${command.replace(/\n/g, " ").trim()}`, + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "Hidden=false", + ]; + return lines.join("\n") + "\n"; +} + +/** A file name for a new entry that doesn't collide with `taken`. */ +export function desktopFileName(name: string, taken: string[]): string { + const slug = name.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "startup-app"; + let id = `${slug}.desktop`; + for (let i = 2; taken.includes(id); i++) id = `${slug}-${i}.desktop`; + return id; +} + +// --------------------------------------------------------------------------- +// systemd + +export interface UnitFile { + unit: string; + state: string; + preset?: string; +} + +/** `systemctl --user list-unit-files --no-legend --plain` lines. */ +export function parseUnitFiles(out: string): UnitFile[] { + return out.split("\n").map((l) => l.trim().split(/\s+/)).filter((w) => w.length >= 2 && w[0].includes(".")) + .map(([unit, state, preset]) => ({ unit, state, preset: preset && preset !== "-" ? preset : undefined })); +} + +export interface UnitInfo { + id: string; + description?: string; + activeState?: string; + subState?: string; + fragmentPath?: string; + wantedBy: string[]; + execStart?: string; +} + +/** `systemctl show -p …` for several units: blocks of Key=Value separated by blank lines. */ +export function parseShow(out: string): UnitInfo[] { + return out.split(/\n\s*\n/).map((block) => { + const kv: Record<string, string> = {}; + for (const line of block.split("\n")) { + const eq = line.indexOf("="); + if (eq > 0) kv[line.slice(0, eq)] = line.slice(eq + 1); + } + return kv; + }).filter((kv) => kv.Id).map((kv) => ({ + id: kv.Id, + description: kv.Description || undefined, + activeState: kv.ActiveState || undefined, + subState: kv.SubState || undefined, + fragmentPath: kv.FragmentPath || undefined, + wantedBy: (kv.WantedBy ?? "").split(/\s+/).filter(Boolean), + execStart: kv.ExecStart ? execFromShow(kv.ExecStart) : undefined, + })); +} + +/** `{ path=/usr/bin/foo ; argv[]=/usr/bin/foo --bar ; … }` → `/usr/bin/foo --bar`. */ +export function execFromShow(v: string): string | undefined { + return v.match(/argv\[\]=([^;]*)/)?.[1]?.trim() || undefined; +} + +// --------------------------------------------------------------------------- +// Hyprland + +export interface ExecOnce { + command: string; + file: string; + line: number; + /** `exec-once` or `exec` (run again at each reload). */ + keyword: "exec-once" | "exec"; +} + +/** `exec-once` lines of a Hyprland config, and the files it `source`s. */ +export function parseHyprland(text: string, file: string): { execs: ExecOnce[]; sources: string[] } { + const execs: ExecOnce[] = []; + const sources: string[] = []; + text.split("\n").forEach((raw, i) => { + // `##` is a literal #; a single # starts a comment. + const line = stripComment(raw).trim(); + const m = line.match(/^(exec-once|exec|source)\s*=\s*(.+)$/); + if (!m) return; + if (m[1] === "source") sources.push(m[2].trim()); + else execs.push({ command: m[2].trim(), file, line: i + 1, keyword: m[1] as "exec-once" | "exec" }); + }); + return { execs, sources }; +} + +/** Removes a `# comment`; `##` stands for a literal `#`. */ +function stripComment(line: string): string { + let out = ""; + for (let i = 0; i < line.length; i++) { + if (line[i] !== "#") out += line[i]; + else if (line[i + 1] === "#") out += line[i++]; + else break; + } + return out; +} + +/** A `source =` path relative to the config, with `~` expanded. */ +export function resolveSource(path: string, fromFile: string, home: string): string { + if (path === "~" || path.startsWith("~/")) return home + path.slice(1); + if (path.startsWith("$HOME/")) return home + path.slice(5); + if (path.startsWith("/")) return path; + return `${fromFile.slice(0, fromFile.lastIndexOf("/"))}/${path}`; +} + +export function tilde(p: string, home: string): string { + return home && (p === home || p.startsWith(home + "/")) ? "~" + p.slice(home.length) : p; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} diff --git a/extensions/startup-apps/lib_test.ts b/extensions/startup-apps/lib_test.ts new file mode 100644 index 0000000..ba8a39b --- /dev/null +++ b/extensions/startup-apps/lib_test.ts @@ -0,0 +1,172 @@ +import { assert, assertEquals } from "@std/assert"; +import { + cleanExec, + desktopFileName, + execFromShow, + isEnabled, + mergeAutostart, + newDesktopFile, + parseDesktop, + parseHyprland, + parseShow, + parseUnitFiles, + programName, + resolveSource, + setDesktopKey, + showsIn, + toggledText, +} from "./lib.ts"; + +const KEYRING = `[Desktop Entry] +Type=Application +Name=SSH Key Agent +Name[fr]=Agent de clés SSH +Comment=GNOME Keyring: SSH Agent +Exec=/usr/bin/gnome-keyring-daemon --start --components=ssh +OnlyShowIn=GNOME;Unity;MATE; +X-GNOME-Autostart-Phase=PreDisplayServer +X-GNOME-AutoRestart=false +X-GNOME-Autostart-Notify=true +X-GNOME-Bugzilla-Component=general + +[Desktop Action Quit] +Name=Quit +Exec=pkill gnome-keyring +`; + +const NM = `[Desktop Entry] +Name=Network +Comment=Manage your network connections +Icon=nm-device-wireless +Exec=nm-applet %U +Terminal=false +Type=Application +NotShowIn=KDE;GNOME; +X-GNOME-Autostart-enabled=true +X-GNOME-Autostart-Delay=3 +`; + +Deno.test("parseDesktop reads the main group only, without translations", () => { + const e = parseDesktop(KEYRING); + assertEquals(e.Name, "SSH Key Agent"); + assertEquals(e.Exec, "/usr/bin/gnome-keyring-daemon --start --components=ssh"); + assertEquals(e["Name[fr]"], undefined); +}); + +Deno.test("isEnabled honours Hidden and X-GNOME-Autostart-enabled", () => { + assert(isEnabled(parseDesktop(NM))); + assert(!isEnabled({ Hidden: "true" })); + assert(!isEnabled({ "X-GNOME-Autostart-enabled": "false" })); + assert(isEnabled({ Hidden: "false" })); +}); + +Deno.test("showsIn applies OnlyShowIn and NotShowIn to XDG_CURRENT_DESKTOP", () => { + const keyring = parseDesktop(KEYRING); + assert(showsIn(keyring, "ubuntu:GNOME")); + assert(!showsIn(keyring, "Hyprland")); + assert(showsIn(keyring, "")); + assert(!showsIn(parseDesktop(NM), "KDE")); + assert(showsIn(parseDesktop(NM), "sway")); +}); + +Deno.test("cleanExec and programName", () => { + assertEquals(cleanExec("nm-applet %U"), "nm-applet"); + assertEquals(programName("env GDK_BACKEND=x11 /usr/bin/discord --start-minimized %U"), "discord"); + assertEquals(programName("FOO=1 waybar -c ~/.config/waybar/config"), "waybar"); + assertEquals(programName(`"/opt/My App/app" --x`), "app"); +}); + +Deno.test("setDesktopKey edits, adds and removes keys in the main group", () => { + const hidden = setDesktopKey(KEYRING, "Hidden", "true"); + assertEquals(parseDesktop(hidden).Hidden, "true"); + // Added to [Desktop Entry], not to the action group that follows. + assert(hidden.indexOf("Hidden=true") < hidden.indexOf("[Desktop Action Quit]")); + assertEquals(hidden.split("\n").filter((l) => l === "").length, 2); + const again = setDesktopKey(hidden, "Hidden", "false"); + assertEquals(again.match(/Hidden=/g)?.length, 1); + assertEquals(parseDesktop(setDesktopKey(again, "Hidden", undefined)).Hidden, undefined); + assertEquals(setDesktopKey("", "Hidden", "true"), "[Desktop Entry]\nHidden=true\n"); +}); + +Deno.test("mergeAutostart: user files replace system ones", () => { + const entries = mergeAutostart( + [ + { path: "/home/ada/.config/autostart/nm-applet.desktop", text: "[Desktop Entry]\nHidden=true\n" }, + { path: "/home/ada/.config/autostart/syncthing.desktop", text: newDesktopFile("Syncthing", "syncthing serve") }, + ], + [ + { path: "/etc/xdg/autostart/nm-applet.desktop", text: NM }, + { path: "/etc/xdg/autostart/gnome-keyring-ssh.desktop", text: KEYRING }, + ], + "Hyprland", + ); + assertEquals(entries.map((e) => [e.name, e.origin, e.enabled]), [ + ["Network", "override", false], + ["SSH Key Agent", "system", true], + ["Syncthing", "user", true], + ]); + const nm = entries[0]; + assertEquals(nm.exec, "nm-applet"); + assertEquals(nm.delay, 3); + assertEquals(nm.systemPath, "/etc/xdg/autostart/nm-applet.desktop"); + assert(entries[1].otherDesktop); + assert(!nm.otherDesktop); +}); + +Deno.test("toggledText writes a GNOME Tweaks style override", () => { + const off = toggledText(undefined, NM, false); + const e = parseDesktop(off); + assertEquals([e.Hidden, e["X-GNOME-Autostart-enabled"], e.Exec], ["true", "false", "nm-applet %U"]); + assert(!isEnabled(e)); + const on = parseDesktop(toggledText(off, NM, true)); + assert(isEnabled(on)); + assertEquals(on["X-GNOME-Autostart-enabled"], "true"); + // A user entry without the GNOME key doesn't get one when turned back on. + const mine = newDesktopFile("Syncthing", "syncthing serve").replace("X-GNOME-Autostart-enabled=true\n", ""); + assertEquals(parseDesktop(toggledText(mine, undefined, true))["X-GNOME-Autostart-enabled"], undefined); +}); + +Deno.test("newDesktopFile and desktopFileName", () => { + const e = parseDesktop(newDesktopFile("Élément", "element-desktop --hidden", "Matrix")); + assertEquals([e.Type, e.Name, e.Exec, e.Comment], ["Application", "Élément", "element-desktop --hidden", "Matrix"]); + assertEquals(desktopFileName("Élément Desktop", []), "element-desktop.desktop"); + assertEquals(desktopFileName("Syncthing", ["syncthing.desktop", "syncthing-2.desktop"]), "syncthing-3.desktop"); + assertEquals(desktopFileName("!!!", []), "startup-app.desktop"); +}); + +Deno.test("parseUnitFiles and parseShow read systemctl output", () => { + assertEquals(parseUnitFiles("syncthing.service enabled enabled\nmako.service enabled -\n\n"), [ + { unit: "syncthing.service", state: "enabled", preset: "enabled" }, + { unit: "mako.service", state: "enabled", preset: undefined }, + ]); + const show = parseShow( + "Id=syncthing.service\nDescription=Syncthing\nActiveState=active\nSubState=running\n" + + "FragmentPath=/usr/lib/systemd/user/syncthing.service\nWantedBy=default.target\n" + + "ExecStart={ path=/usr/bin/syncthing ; argv[]=/usr/bin/syncthing serve --no-browser ; ignore_errors=no }\n\n" + + "Id=mako.service\nActiveState=failed\nWantedBy=\n", + ); + assertEquals(show[0].execStart, "/usr/bin/syncthing serve --no-browser"); + assertEquals(show[0].wantedBy, ["default.target"]); + assertEquals([show[1].id, show[1].activeState, show[1].description], ["mako.service", "failed", undefined]); + assertEquals(execFromShow("{ path=/bin/true ; argv[]= ; }"), undefined); +}); + +Deno.test("parseHyprland finds exec-once lines and sources", () => { + const conf = [ + "monitor=,preferred,auto,1", + "exec-once = waybar", + " exec-once=hyprpaper # wallpaper", + "# exec-once = disabled", + `exec = notify-send "a ## b"`, + "source = ~/.config/hypr/apps.conf", + ].join("\n"); + const { execs, sources } = parseHyprland(conf, "/home/ada/.config/hypr/hyprland.conf"); + assertEquals(execs.map((e) => [e.command, e.line, e.keyword]), [ + ["waybar", 2, "exec-once"], + ["hyprpaper", 3, "exec-once"], + [`notify-send "a # b"`, 5, "exec"], + ]); + assertEquals(sources, ["~/.config/hypr/apps.conf"]); + assertEquals(resolveSource("~/x.conf", "/h/.config/hypr/hyprland.conf", "/h"), "/h/x.conf"); + assertEquals(resolveSource("conf/a.conf", "/h/.config/hypr/hyprland.conf", "/h"), "/h/.config/hypr/conf/a.conf"); +}); diff --git a/extensions/stocks/api.ts b/extensions/stocks/api.ts new file mode 100644 index 0000000..ab81217 --- /dev/null +++ b/extensions/stocks/api.ts @@ -0,0 +1,191 @@ +// The Finnhub REST API (free plan: 60 calls a minute, US quotes), with caches +// so that typing and reopening stay under the limit. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { + isoDay, + type MarketStatus, + type Metrics, + type News, + type Profile, + type Quote, + type SymbolMatch, +} from "./lib.ts"; + +const API = "https://finnhub.io/api/v1"; +export const REGISTER_URL = "https://finnhub.io/register"; +export const DASHBOARD_URL = "https://finnhub.io/dashboard"; + +type Json = Parameters<typeof storage.set>[1]; + +export class AuthError extends Error {} +export class RateLimited extends Error {} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +export function apiKey(preferences: Record<string, unknown>): string { + const key = String(preferences.apiKey ?? "").trim(); + if (!key) throw new AuthError("Add your free Finnhub API key"); + return key; +} + +async function request<T>(key: string, path: string, signal?: AbortSignal): Promise<T> { + let res: Response; + try { + res = await fetch(`${API}${path}`, { signal, headers: { "X-Finnhub-Token": key, Accept: "application/json" } }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Finnhub is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (res.status === 401) throw new AuthError("Finnhub rejected the API key"); + if (res.status === 429) throw new RateLimited("Finnhub's limit of 60 calls a minute is reached, try again shortly"); + if (res.status === 403) { + throw new Error("Finnhub's free plan doesn't include this (only US stocks have quotes on the free plan)"); + } + if (!res.ok) throw new Error(`Finnhub answered ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`); + return JSON.parse(text) as T; +} + +interface Cached<T> { + at: number; + data: T; +} + +const memory = new Map<string, Cached<unknown>>(); + +/** Set when the last answer came from the cache because Finnhub refused or was unreachable. */ +export let stale = false; + +/** Starts a new load: `stale` then says whether any of its answers was a saved one. */ +export function newRound(): void { + stale = false; +} + +/** A GET answer, fresh for `ttl` seconds; the saved one when Finnhub refuses (rate limit) or is unreachable. */ +async function cached<T>( + key: string, + path: string, + ttl: number, + signal?: AbortSignal, + persist = true, +): Promise<T> { + const id = `cache:${path}`; + const hit = (memory.get(id) as Cached<T> | undefined) ?? (persist ? await load<Cached<T>>(id) : undefined); + if (hit && Date.now() - hit.at < ttl * 1000) { + memory.set(id, hit); + return hit.data; + } + try { + const data = await request<T>(key, path, signal); + const entry = { at: Date.now(), data }; + memory.set(id, entry); + if (persist) await save(id, entry); + return data; + } catch (e) { + if ((e as Error).name === "AbortError" || e instanceof AuthError || !hit) throw e; + stale = true; + return hit.data; + } +} + +const q = (symbol: string) => encodeURIComponent(symbol); + +export const quote = (key: string, symbol: string, signal?: AbortSignal) => + cached<Quote>(key, `/quote?symbol=${q(symbol)}`, 30, signal); + +/** Company profiles hardly change: kept a week. Empty for funds and foreign listings. */ +export const profile = (key: string, symbol: string, signal?: AbortSignal) => + cached<Profile>(key, `/stock/profile2?symbol=${q(symbol)}`, 7 * 86400, signal); + +export const metrics = (key: string, symbol: string, signal?: AbortSignal) => + cached<Metrics>(key, `/stock/metric?symbol=${q(symbol)}&metric=all`, 86400, signal); + +export const marketStatus = (key: string, signal?: AbortSignal) => + cached<MarketStatus>(key, `/stock/market-status?exchange=US`, 300, signal, false); + +export async function search(key: string, query: string, signal?: AbortSignal): Promise<SymbolMatch[]> { + const res = await cached<{ count: number; result: SymbolMatch[] }>( + key, + `/search?q=${encodeURIComponent(query.trim())}`, + 86400, + signal, + false, + ); + return res.result ?? []; +} + +/** The last two weeks of headlines, newest first. */ +export async function news(key: string, symbol: string, signal?: AbortSignal): Promise<News[]> { + const to = new Date(); + const from = new Date(to.getTime() - 14 * 86400_000); + const list = await cached<News[]>( + key, + `/company-news?symbol=${q(symbol)}&from=${isoDay(from)}&to=${isoDay(to)}`, + 1800, + signal, + false, + ); + return [...(list ?? [])].sort((a, b) => b.datetime - a.datetime); +} + +// --------------------------------------------------------------------------- +// Watchlist + +/** Shown until the user changes the watchlist. */ +export const DEFAULT_WATCHLIST = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]; + +export const watchlist = async () => await load<string[]>("watchlist") ?? DEFAULT_WATCHLIST; +export const setWatchlist = (list: string[]) => save("watchlist", list); + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free at finnhub.io: 60 calls a minute, US stocks", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Finnhub", + "", + "Quotes come from **Finnhub**, whose free plan needs an API key:", + "", + "1. Create a free account on **finnhub.io/register** (Enter opens it).", + "2. Copy the **API key** shown on your Finnhub dashboard.", + "3. Paste it in **Settings → Extensions → Stocks → Configure**.", + "", + "The free plan allows 60 calls a minute and has real-time quotes for US stocks.", + ].join("\n"), + }, + actions: [ + open(REGISTER_URL, "Create a Free Finnhub Account", { icon: Icons.key }), + open(DASHBOARD_URL, "Open Finnhub Dashboard", { icon: Icons.external }), + ], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + if (e instanceof RateLimited) { + return [{ + id: "rate-limit", + title: e.message, + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { markdown: "Finnhub's free plan allows 60 calls a minute. Quotes are cached for 30 seconds." }, + }]; + } + throw e; + } +} diff --git a/extensions/stocks/backslash.toml b/extensions/stocks/backslash.toml new file mode 100644 index 0000000..fae8a44 --- /dev/null +++ b/extensions/stocks/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "stocks" +title = "Stocks" +description = "A stock watchlist with live quotes, day ranges, company profiles and news, from Finnhub's free API" +version = "1.0.0" +author = "Backslash" +icon = "bs-chart-up" +license = "MIT" + +# The Finnhub API, and static2.finnhub.io where it serves company logos. +[permissions] +net = ["finnhub.io", "static2.finnhub.io"] + +[[commands]] +name = "stocks" +title = "Stock Watchlist" +description = "Your stocks with price and change; type a symbol or a company to search" +icon = "bs-chart-up" +keywords = [ + "stocks", + "stock", + "shares", + "ticker", + "quote", + "market", + "nasdaq", + "finnhub", + "bourse", + "actions", + "cours", + "marché", +] + +[[preferences]] +name = "apiKey" +title = "Finnhub API key" +description = "Free at finnhub.io/register; the key is on finnhub.io/dashboard" +type = "secret" diff --git a/extensions/stocks/commands/stocks.ts b/extensions/stocks/commands/stocks.ts new file mode 100644 index 0000000..ec606b2 --- /dev/null +++ b/extensions/stocks/commands/stocks.ts @@ -0,0 +1,60 @@ +import { type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { apiKey, guarded, marketStatus, newRound, profile, quote, search, stale, watchlist } from "../api.ts"; +import { marketLine, rankMatches, tickerLine, titleCase } from "../lib.ts"; +import { type Stock, stockItem } from "../views.ts"; + +const SEARCH_QUOTES = 6; +let market: string | undefined; + +/** Quote and profile; a stock still shows if one of them fails. */ +async function enrich(key: string, s: Stock, signal: AbortSignal, withProfile: boolean): Promise<Stock> { + const soft = <T>(p: Promise<T>) => + p.catch((e) => { + if ((e as Error).name === "AbortError") throw e; + return undefined; + }); + const [q, p] = await Promise.all([ + soft(quote(key, s.symbol, signal)), + withProfile ? soft(profile(key, s.symbol, signal)) : Promise.resolve(undefined), + ]); + return { ...s, quote: q, profile: p && Object.keys(p).length ? p : undefined, name: p?.name || s.name }; +} + +export default (ctx: Context) => + list({ + placeholder: "Search a symbol or a company: AAPL, tesla, s&p 500 etf…", + empty: { title: "No symbol found", description: "Try a ticker (MSFT) or a company name", icon: Icons.chartUp }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + const watched = items.filter((i) => i.section === "Watchlist").length; + return [ + watched ? `${watched} in your watchlist` : `${items.length} result${items.length === 1 ? "" : "s"}`, + market, + stale ? "some quotes are saved ones (rate limit)" : "Finnhub", + ].filter(Boolean).join(" · "); + }, + search: (query, { signal }) => + guarded(async (): Promise<Item[]> => { + const key = apiKey(ctx.preferences); + newRound(); + const watched = await watchlist(); + if (!query.trim()) { + const [stocks, status] = await Promise.all([ + Promise.all(watched.map((symbol) => enrich(key, { symbol, name: symbol }, signal, true))), + marketStatus(key, signal).catch(() => undefined), + ]); + market = marketLine(status); + const first = stocks.find((s) => s.quote && (s.quote.c || s.quote.pc)); + updateCommandMetadata({ subtitle: first ? tickerLine(first.symbol, first.quote!) : null }).catch(() => {}); + return stocks.map((s) => stockItem(s, { key, watched: true, section: "Watchlist" })); + } + const matches = rankMatches(await search(key, query, signal), query); + const stocks = await Promise.all( + matches.map((m, i) => { + const s: Stock = { symbol: m.symbol, name: titleCase(m.description), type: m.type || undefined }; + return i < SEARCH_QUOTES ? enrich(key, s, signal, i < 3 || watched.includes(m.symbol)) : Promise.resolve(s); + }), + ); + return stocks.map((s) => stockItem(s, { key, watched: watched.includes(s.symbol), section: "Results" })); + }), + }); diff --git a/extensions/stocks/lib.ts b/extensions/stocks/lib.ts new file mode 100644 index 0000000..54e2187 --- /dev/null +++ b/extensions/stocks/lib.ts @@ -0,0 +1,248 @@ +// Finnhub answers and their formatting. Pure, tested in lib_test.ts. + +import type { Color } from "@backslash/api"; + +// --------------------------------------------------------------------------- +// Shapes + +/** `GET /quote`: c current, d change, dp change %, h/l day high/low, o open, pc previous close, t time (s). */ +export interface Quote { + c: number; + d: number | null; + dp: number | null; + h: number; + l: number; + o: number; + pc: number; + t: number; +} + +/** `GET /stock/profile2`. `marketCapitalization` is in millions of `currency`. */ +export interface Profile { + country?: string; + currency?: string; + exchange?: string; + finnhubIndustry?: string; + ipo?: string; + logo?: string; + marketCapitalization?: number; + name?: string; + phone?: string; + shareOutstanding?: number; + ticker?: string; + weburl?: string; +} + +/** One result of `GET /search`. */ +export interface SymbolMatch { + description: string; + displaySymbol: string; + symbol: string; + type: string; +} + +/** One article of `GET /company-news`. */ +export interface News { + category?: string; + datetime: number; + headline: string; + id: number; + image?: string; + related?: string; + source?: string; + summary?: string; + url: string; +} + +/** `GET /stock/metric?metric=all`, the few values we show. */ +export interface Metrics { + metric?: { + "52WeekHigh"?: number; + "52WeekLow"?: number; + "52WeekHighDate"?: string; + "52WeekLowDate"?: string; + peBasicExclExtraTTM?: number; + peTTM?: number; + beta?: number; + dividendYieldIndicatedAnnual?: number; + epsTTM?: number; + }; +} + +/** `GET /stock/market-status?exchange=US`. */ +export interface MarketStatus { + exchange: string; + holiday: string | null; + isOpen: boolean; + session: "pre-market" | "regular" | "post-market" | null | string; + timezone?: string; + t?: number; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** Finnhub answers zeros for a symbol it has no quote for. */ +export const hasQuote = (q: Quote | undefined): q is Quote => !!q && (q.c !== 0 || q.pc !== 0); + +/** `189.84`, `0.0123`: prices without a currency sign (Finnhub mixes exchanges). */ +export function price(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const abs = Math.abs(value); + const digits = abs > 0 && abs < 1 + ? { maximumSignificantDigits: 4 } + : { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + return new Intl.NumberFormat("en-US", digits).format(value); +} + +/** `$189.84`, `€12.30`, or a bare price for unknown currencies. */ +export function money(value: number | null | undefined, currency?: string): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + if (!currency || !/^[A-Z]{3}$/.test(currency)) return price(value); + try { + const abs = Math.abs(value); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + ...(abs > 0 && abs < 1 + ? { maximumSignificantDigits: 4 } + : { minimumFractionDigits: 2, maximumFractionDigits: 2 }), + }).format(value); + } catch { + return price(value); + } +} + +export function signed(value: number | null | undefined, suffix = ""): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + return `${sign}${price(Math.abs(value))}${suffix}`; +} + +export function percent(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return "—"; + return `${value > 0 ? "+" : value < 0 ? "−" : ""}${Math.abs(value).toFixed(2)} %`; +} + +export const changeColor = (value: number | null | undefined): Color | undefined => + !value ? undefined : value > 0 ? "green" : "red"; + +/** Market cap in millions → `$2.9T`, `€412.3B`. */ +export function marketCap(millions: number | undefined, currency = "USD"): string { + if (!millions || !Number.isFinite(millions)) return "—"; + const value = millions * 1e6; + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + notation: "compact", + maximumFractionDigits: 2, + }).format(value); + } catch { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format(value); + } +} + +/** Where the price is between the day's low and high, from 0 to 1. */ +export function dayPosition(q: Quote): number | undefined { + if (!(q.h > q.l)) return undefined; + return Math.max(0, Math.min(1, (q.c - q.l) / (q.h - q.l))); +} + +/** `AAPL 189.84 ▲ 1.17%` for the root search subtitle. */ +export function tickerLine(symbol: string, q: Quote): string { + const arrow = q.dp === null ? "" : q.dp >= 0 ? " ▲" : " ▼"; + return `${symbol} ${price(q.c)}${arrow}${q.dp === null ? "" : ` ${Math.abs(q.dp).toFixed(2)}%`}`; +} + +/** "5 min", "3 h", "2 d", then a date, from Unix seconds. */ +export function ago(seconds: number, now = Date.now()): string { + const s = Math.max(0, Math.round(now / 1000 - seconds)); + if (s < 60) return "now"; + if (s < 3600) return `${Math.floor(s / 60)} min`; + if (s < 86400) return `${Math.floor(s / 3600)} h`; + if (s < 30 * 86400) return `${Math.floor(s / 86400)} d`; + return new Date(seconds * 1000).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }); +} + +/** "Wed 28 Oct, 16:00" from Unix seconds. */ +export function quoteTime(seconds: number): string { + if (!seconds) return ""; + return new Date(seconds * 1000).toLocaleString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** `YYYY-MM-DD` in UTC. */ +export const isoDay = (d: Date) => d.toISOString().slice(0, 10); + +/** "Open", "Pre-market", "After hours", "Closed (Thanksgiving)". */ +export function marketLine(m: MarketStatus | undefined): string | undefined { + if (!m) return undefined; + if (m.holiday) return `US market closed (${m.holiday})`; + if (m.session === "regular" || (m.isOpen && !m.session)) return "US market open"; + if (m.session === "pre-market") return "US pre-market"; + if (m.session === "post-market") return "US after hours"; + return "US market closed"; +} + +// --------------------------------------------------------------------------- +// Symbols + +/** What the user typed → a symbol to look up: ` aapl ` → `AAPL`. */ +export const normalizeSymbol = (s: string) => s.trim().toUpperCase().replace(/\s+/g, ""); + +/** + * Search results worth showing: stocks and funds, the primary listing first + * (`AAPL` before `AAPL.MX`), unless the query names an exchange suffix. + */ +export function rankMatches(matches: SymbolMatch[], query: string, limit = 12): SymbolMatch[] { + const q = normalizeSymbol(query); + const seen = new Set<string>(); + const kept = matches.filter((m) => { + if (!m.symbol || seen.has(m.symbol)) return false; + seen.add(m.symbol); + return q.includes(".") || !m.symbol.includes("."); + }); + const score = (m: SymbolMatch) => (m.symbol === q ? 0 : m.symbol.startsWith(q) ? 1 : 2); + return kept.map((m, i) => ({ m, i })).sort((a, b) => score(a.m) - score(b.m) || a.i - b.i).slice(0, limit).map(( + x, + ) => x.m); +} + +/** "APPLE INC" → "Apple Inc", leaving short tickers-like words (ETF, AG) upper case. */ +export function titleCase(name: string): string { + if (name !== name.toUpperCase()) return name; + return name.toLowerCase().replace( + /\b([a-z])([a-z]*)/g, + (w, first: string, rest: string) => + ["etf", "ag", "se", "sa", "nv", "plc", "spa", "usd", "ii", "iii"].includes(w) + ? w.toUpperCase() + : first.toUpperCase() + rest, + ); +} + +export const domain = (url: string | undefined) => { + if (!url) return undefined; + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +}; + +export const yahooUrl = (symbol: string) => `https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}`; + +/** Moves `symbol` one place up or down. */ +export function move(list: string[], symbol: string, by: -1 | 1): string[] { + const i = list.indexOf(symbol); + const j = i + by; + if (i < 0 || j < 0 || j >= list.length) return list; + const out = [...list]; + [out[i], out[j]] = [out[j], out[i]]; + return out; +} diff --git a/extensions/stocks/lib_test.ts b/extensions/stocks/lib_test.ts new file mode 100644 index 0000000..49fe922 --- /dev/null +++ b/extensions/stocks/lib_test.ts @@ -0,0 +1,134 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + changeColor, + dayPosition, + domain, + hasQuote, + marketCap, + marketLine, + money, + move, + normalizeSymbol, + percent, + type Quote, + rankMatches, + signed, + type SymbolMatch, + tickerLine, + titleCase, + yahooUrl, +} from "./lib.ts"; +import { stockItem } from "./views.ts"; + +// Shaped like `GET /quote?symbol=AAPL`. +const aapl: Quote = { c: 189.84, d: 2.2, dp: 1.1724, h: 190.3, l: 187.1, o: 188, pc: 187.64, t: 1793180000 }; +const unknown: Quote = { c: 0, d: null, dp: null, h: 0, l: 0, o: 0, pc: 0, t: 0 }; + +// Shaped like `GET /search?q=tesla`. +const matches: SymbolMatch[] = [ + { description: "TESLA INC", displaySymbol: "TL0.DE", symbol: "TL0.DE", type: "Common Stock" }, + { description: "DIREXION DAILY TSLA BULL 2X", displaySymbol: "TSLL", symbol: "TSLL", type: "ETP" }, + { description: "TESLA INC", displaySymbol: "TSLA", symbol: "TSLA", type: "Common Stock" }, + { description: "TESLA INC", displaySymbol: "TSLA.MX", symbol: "TSLA.MX", type: "Common Stock" }, + { description: "TESLA INC", displaySymbol: "TSLA", symbol: "TSLA", type: "Common Stock" }, +]; + +Deno.test("quotes", () => { + assertEquals(hasQuote(aapl), true); + assertEquals(hasQuote(unknown), false); + assertEquals(hasQuote(undefined), false); + assertEquals(dayPosition(aapl)?.toFixed(3), "0.856"); + assertEquals(dayPosition({ ...aapl, h: 10, l: 10 }), undefined); + assertEquals(tickerLine("AAPL", aapl), "AAPL 189.84 ▲ 1.17%"); + assertEquals(tickerLine("MSFT", { ...aapl, c: 415.5, dp: -0.7406 }), "MSFT 415.50 ▼ 0.74%"); +}); + +Deno.test("formatting", () => { + assertEquals(money(189.84, "USD"), "$189.84"); + assertEquals(money(12.3, "EUR"), "€12.30"); + assertEquals(money(0.01234, "USD"), "$0.01234"); + assertEquals(money(5, "XX"), "5.00"); + assertEquals(money(5, undefined), "5.00"); + assertEquals(money(null), "—"); + assertEquals(signed(2.2), "+2.20"); + assertEquals(signed(-3.1), "−3.10"); + assertEquals(percent(1.1724), "+1.17 %"); + assertEquals(percent(-0.7406), "−0.74 %"); + assertEquals(percent(null), "—"); + assertEquals(changeColor(-1), "red"); + assertEquals(changeColor(0), undefined); + assertEquals(marketCap(2905123.4, "USD"), "$2.91T"); + assertEquals(marketCap(412300, "EUR"), "€412.3B"); + assertEquals(marketCap(undefined), "—"); +}); + +Deno.test("market status", () => { + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: true, session: "regular" }), "US market open"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: "pre-market" }), "US pre-market"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: "post-market" }), "US after hours"); + assertEquals(marketLine({ exchange: "US", holiday: null, isOpen: false, session: null }), "US market closed"); + assertEquals( + marketLine({ exchange: "US", holiday: "Thanksgiving Day", isOpen: false, session: null }), + "US market closed (Thanksgiving Day)", + ); + assertEquals(marketLine(undefined), undefined); +}); + +Deno.test("search results", () => { + assertEquals(rankMatches(matches, "tesla").map((m) => m.symbol), ["TSLL", "TSLA"]); + assertEquals(rankMatches(matches, "tsla").map((m) => m.symbol), ["TSLA", "TSLL"]); + assertEquals(rankMatches(matches, "tsla.mx").map((m) => m.symbol), ["TSLA.MX", "TL0.DE", "TSLL", "TSLA"]); + assertEquals(normalizeSymbol(" brk b "), "BRKB"); + assertEquals(titleCase("APPLE INC"), "Apple Inc"); + assertEquals(titleCase("SPDR S&P 500 ETF TRUST"), "Spdr S&P 500 ETF Trust"); + assertEquals(titleCase("Alphabet Inc"), "Alphabet Inc"); +}); + +Deno.test("helpers", () => { + assertEquals(move(["A", "B", "C"], "B", -1), ["B", "A", "C"]); + assertEquals(move(["A", "B", "C"], "C", 1), ["A", "B", "C"]); + assertEquals(domain("https://www.apple.com/"), "apple.com"); + assertEquals(yahooUrl("BRK.B"), "https://finance.yahoo.com/quote/BRK.B"); + const now = 1793180000 * 1000; + assertEquals(ago(1793180000 - 300, now), "5 min"); + assertEquals(ago(1793180000 - 3 * 86400, now), "3 d"); +}); + +Deno.test("stock item", () => { + const item = stockItem({ + symbol: "AAPL", + name: "Apple Inc", + quote: aapl, + profile: { + currency: "USD", + exchange: "NASDAQ NMS - GLOBAL MARKET", + finnhubIndustry: "Technology", + logo: "https://static2.finnhub.io/file/publicdatany/finnhubimage/stock_logo/AAPL.png", + marketCapitalization: 2905123.4, + name: "Apple Inc", + weburl: "https://www.apple.com/", + }, + }, { key: "k", watched: true, section: "Watchlist" }); + assertEquals(item.icon, "https://static2.finnhub.io/file/publicdatany/finnhubimage/stock_logo/AAPL.png"); + assertEquals(item.accessories?.map((a) => a.text), ["+1.17 %", "$189.84"]); + assertEquals(item.detail?.stats?.[1], { label: "Change", value: "+2.20", color: "green" }); + const range = item.detail?.metadata?.find((m) => m.label === "Day range"); + assertEquals(range?.value, "$187.10 – $190.30"); + assertEquals(range?.progress?.toFixed(2), "0.86"); + assertEquals(item.actions?.map((a) => a.title).slice(0, 5), [ + "Show News", + "Show Company Details", + "Remove from Watchlist", + "Move Up", + "Move Down", + ]); + + const foreign = stockItem({ symbol: "TL0.DE", name: "Tesla Inc", quote: unknown, type: "Common Stock" }, { + key: "k", + watched: false, + }); + assertEquals(foreign.icon, { source: "bs-chart-up", tint: "gray" }); + assertEquals(foreign.accessories, [{ tag: "Common Stock" }]); + assertEquals(foreign.detail?.stats, undefined); +}); diff --git a/extensions/stocks/views.ts b/extensions/stocks/views.ts new file mode 100644 index 0000000..8f66e14 --- /dev/null +++ b/extensions/stocks/views.ts @@ -0,0 +1,262 @@ +// Stocks as launcher items, the company page and the news list. + +import { + type Accessory, + type Action, + action, + copy, + detail, + type DetailSpec, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + toast, +} from "@backslash/api"; +import { guarded, metrics, news, profile, setWatchlist, watchlist } from "./api.ts"; +import { + ago, + changeColor, + dayPosition, + domain, + hasQuote, + marketCap, + money, + move, + type News, + percent, + type Profile, + type Quote, + quoteTime, + signed, + yahooUrl, +} from "./lib.ts"; + +export interface Stock { + symbol: string; + name: string; + type?: string; + quote?: Quote; + profile?: Profile; +} + +const logoOf = (p: Profile | undefined) => + p?.logo && p.logo.startsWith("https://static2.finnhub.io/") ? p.logo : undefined; + +export function quoteMetadata(s: Stock): Metadata[] { + const q = s.quote; + const p = s.profile; + const cur = p?.currency; + const lines: Metadata[] = []; + if (hasQuote(q)) { + const pos = dayPosition(q); + lines.push( + pos === undefined + ? meta.text("Day range", `${money(q.l, cur)} – ${money(q.h, cur)}`) + : meta.progress("Day range", pos, `${money(q.l, cur)} – ${money(q.h, cur)}`), + meta.text("Open", money(q.o, cur)), + meta.text("Previous close", money(q.pc, cur)), + ); + } + if (p?.marketCapitalization) lines.push(meta.text("Market cap", marketCap(p.marketCapitalization, cur))); + if (p?.exchange) lines.push(meta.text("Exchange", p.exchange, { icon: Icons.globe })); + if (p?.finnhubIndustry) lines.push(meta.tags("Industry", [{ text: p.finnhubIndustry, color: "blue" }])); + if (s.type && !p?.finnhubIndustry) lines.push(meta.tags("Type", [s.type])); + if (p?.country) lines.push(meta.text("Country", p.country, { icon: Icons.flag })); + if (p?.ipo) lines.push(meta.text("IPO", p.ipo, { icon: Icons.calendar })); + const site = domain(p?.weburl); + if (site) lines.push(meta.link("Website", site, p!.weburl!)); + if (hasQuote(q) && q.t) lines.push(meta.text("Updated", quoteTime(q.t), { icon: Icons.clock })); + return lines; +} + +export function stockItem(s: Stock, o: { key: string; watched: boolean; section?: string }): Item { + const q = hasQuote(s.quote) ? s.quote : undefined; + const cur = s.profile?.currency; + const up = (q?.d ?? 0) >= 0; + const logo = logoOf(s.profile); + const accessories: Accessory[] = []; + if (o.watched && o.section !== "Watchlist") { + accessories.push({ icon: { source: Icons.starFilled, tint: "yellow" }, tooltip: "In your watchlist" }); + } + if (q) { + accessories.push( + { + text: percent(q.dp), + color: changeColor(q.dp), + icon: { source: up ? Icons.chartUp : Icons.chartDown, tint: changeColor(q.d) }, + tooltip: `${signed(q.d)} today`, + }, + { text: money(q.c, cur), tooltip: `Previous close ${money(q.pc, cur)}` }, + ); + } else if (s.type) { + accessories.push({ tag: s.type }); + } + + const actions: Action[] = [ + action("Show News", (ctx) => newsList(s, o.key, ctx.signal), { icon: Icons.rss, shortcut: "<Ctrl>n" }), + action("Show Company Details", (ctx) => companyView(s, o.key, ctx.signal), { + icon: Icons.info, + shortcut: "<Ctrl>i", + }), + action(o.watched ? "Remove from Watchlist" : "Add to Watchlist", async () => { + const list = await watchlist(); + await setWatchlist(o.watched ? list.filter((x) => x !== s.symbol) : [...list, s.symbol]); + await toast(`${s.symbol} ${o.watched ? "removed from" : "added to"} the watchlist`, { style: "success" }); + return refresh(); + }, { + icon: o.watched ? Icons.starFilled : Icons.star, + shortcut: "<Ctrl>s", + ...(o.watched ? { section: "Watchlist" } : {}), + }), + ]; + if (o.watched && o.section === "Watchlist") { + actions.push( + action("Move Up", async () => { + await setWatchlist(move(await watchlist(), s.symbol, -1)); + return refresh(); + }, { icon: Icons.arrowUp, shortcut: "<Ctrl><Shift>Up", section: "Watchlist" }), + action("Move Down", async () => { + await setWatchlist(move(await watchlist(), s.symbol, 1)); + return refresh(); + }, { icon: Icons.arrowDown, shortcut: "<Ctrl><Shift>Down", section: "Watchlist" }), + ); + } + actions.push( + open(yahooUrl(s.symbol), "Open on Yahoo Finance", { section: "Open" }), + ...(s.profile?.weburl ? [open(s.profile.weburl, "Open Company Website", { section: "Open" })] : []), + copy(s.symbol, "Copy Symbol", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + ...(q ? [copy(money(q.c, cur), "Copy Price", { section: "Copy" })] : []), + ); + + return { + id: `${o.section ?? ""}:${s.symbol}`, + title: s.symbol, + subtitle: s.name, + section: o.section, + icon: logo ?? { source: up ? Icons.chartUp : Icons.chartDown, tint: q ? changeColor(q.d) ?? "gray" : "gray" }, + keywords: [s.name, ...(s.profile?.finnhubIndustry ? [s.profile.finnhubIndustry] : [])], + accessories, + detail: { + image: logo, + stats: q + ? [ + { label: "Price", value: money(q.c, cur) }, + { label: "Change", value: signed(q.d), color: changeColor(q.d) }, + { label: "Today", value: percent(q.dp), color: changeColor(q.dp) }, + ] + : undefined, + markdown: q + ? `**${s.name}**` + : `**${s.name}**\n\nNo quote for ${s.symbol}: Finnhub's free plan has quotes for US stocks only.`, + metadata: quoteMetadata(s), + }, + actions, + }; +} + +/** The company's profile, key figures and latest headlines. */ +export async function companyView(s: Stock, key: string, signal: AbortSignal): Promise<DetailSpec> { + const [p, m, headlines] = await Promise.all([ + s.profile ? Promise.resolve(s.profile) : profile(key, s.symbol, signal).catch(() => undefined), + metrics(key, s.symbol, signal).catch(() => undefined), + news(key, s.symbol, signal).catch(() => [] as News[]), + ]); + const cur = p?.currency; + const x = m?.metric ?? {}; + const q = hasQuote(s.quote) ? s.quote : undefined; + const md = [ + `# ${p?.name || s.name} (${s.symbol})`, + "", + ...(q ? [`**${money(q.c, cur)}** · ${signed(q.d)} (${percent(q.dp)}) today`, ""] : []), + ...(headlines.length + ? [ + "## Latest news", + "", + ...headlines.slice(0, 8).map((n) => + `- [${n.headline.replace(/[[\]]/g, "")}](${n.url}) · ${n.source ?? ""} · ${ago(n.datetime)}` + ), + ] + : ["No headlines in the last two weeks."]), + ]; + const metadata: Metadata[] = [...quoteMetadata({ ...s, profile: p })]; + if (x["52WeekLow"] && x["52WeekHigh"]) { + metadata.push(meta.text("52 weeks", `${money(x["52WeekLow"], cur)} – ${money(x["52WeekHigh"], cur)}`)); + } + const pe = x.peTTM ?? x.peBasicExclExtraTTM; + if (pe) metadata.push(meta.text("P/E (TTM)", pe.toFixed(1))); + if (x.epsTTM) metadata.push(meta.text("EPS (TTM)", money(x.epsTTM, cur))); + if (x.beta) metadata.push(meta.text("Beta", x.beta.toFixed(2))); + if (x.dividendYieldIndicatedAnnual) { + metadata.push(meta.text("Dividend yield", `${x.dividendYieldIndicatedAnnual.toFixed(2)} %`)); + } + if (p?.shareOutstanding) { + metadata.push( + meta.text( + "Shares", + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 2 }).format( + p.shareOutstanding * 1e6, + ), + ), + ); + } + return detail({ + title: `${s.symbol} · ${p?.name || s.name}`, + markdown: md.join("\n"), + stats: q + ? [ + { label: "Price", value: money(q.c, cur) }, + { label: "Today", value: percent(q.dp), color: changeColor(q.dp) }, + ...(p?.marketCapitalization ? [{ label: "Market cap", value: marketCap(p.marketCapitalization, cur) }] : []), + ] + : undefined, + metadata, + status: `${headlines.length} headline${headlines.length === 1 ? "" : "s"} in 14 days · Finnhub`, + actions: [ + action("Show News", (ctx) => newsList(s, key, ctx.signal), { icon: Icons.rss }), + open(yahooUrl(s.symbol), "Open on Yahoo Finance"), + ...(p?.weburl ? [open(p.weburl, "Open Company Website")] : []), + copy(s.symbol, "Copy Symbol", { section: "Copy" }), + ], + }); +} + +export function newsItem(n: News, symbol: string): Item { + return { + id: String(n.id || n.url), + title: n.headline, + subtitle: n.source, + icon: { source: Icons.rss, tint: "orange" }, + keywords: [n.source ?? "", n.category ?? ""], + accessories: [{ text: ago(n.datetime), tooltip: new Date(n.datetime * 1000).toLocaleString("en-GB") }], + detail: { + markdown: [`**${n.headline}**`, ...(n.summary ? ["", n.summary.slice(0, 1200)] : [])].join("\n"), + metadata: [ + ...(n.source ? [meta.text("Source", n.source, { icon: Icons.globe })] : []), + meta.text("Published", new Date(n.datetime * 1000).toLocaleString("en-GB"), { icon: Icons.clock }), + ...(n.related ? [meta.tags("Related", n.related.split(",").filter(Boolean).slice(0, 6))] : []), + ...(domain(n.url) ? [meta.link("Link", domain(n.url)!, n.url)] : []), + ], + }, + actions: [ + open(n.url, "Open Article"), + copy(n.url, "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(`[${n.headline}](${n.url})`, "Copy as Markdown Link", { section: "Copy" }), + open(yahooUrl(symbol), `Open ${symbol} on Yahoo Finance`, { section: "Open" }), + ], + }; +} + +export function newsList(s: Stock, key: string, _signal?: AbortSignal): ListSpec { + return list({ + title: `${s.symbol} News`, + placeholder: `Filter ${s.symbol} headlines…`, + empty: { title: "No headlines", description: "Nothing in the last two weeks", icon: Icons.rss }, + status: (items) => `${items.length} headline${items.length === 1 ? "" : "s"} · last 14 days · Finnhub`, + items: ({ signal }) => guarded(async () => (await news(key, s.symbol, signal)).map((n) => newsItem(n, s.symbol))), + }); +} diff --git a/extensions/system-logs/backslash.toml b/extensions/system-logs/backslash.toml new file mode 100644 index 0000000..a1a3d52 --- /dev/null +++ b/extensions/system-logs/backslash.toml @@ -0,0 +1,54 @@ +[extension] +id = "system-logs" +title = "System Logs" +description = "Errors and warnings of this boot from systemd's journal, grouped by service" +version = "1.0.0" +author = "Backslash" +icon = "bs-activity" +license = "MIT" + +# journalctl reads the system journal (when your account may: systemd-journal +# or adm group) and your own. The terminals only follow a unit's logs live. +[permissions] +run = [ + "journalctl", + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +] + +[[commands]] +name = "logs" +title = "System Logs" +description = "Errors and warnings of this boot by unit, previous boots, and live logs in a terminal" +icon = "bs-activity" +keywords = [ + "logs", + "journal", + "journalctl", + "errors", + "warnings", + "erreurs", + "avertissements", + "journaux", + "systemd", + "syslog", + "dmesg", + "kernel", + "boot", + "démarrage", +] + +[[preferences]] +name = "terminal" +title = "Terminal" +description = "Used to follow logs live. Leave empty to use the first one installed (kitty, alacritty, wezterm, foot, ghostty, GNOME Console, Konsole…)" diff --git a/extensions/system-logs/commands/logs.ts b/extensions/system-logs/commands/logs.ts new file mode 100644 index 0000000..0c7acbb --- /dev/null +++ b/extensions/system-logs/commands/logs.ts @@ -0,0 +1,352 @@ +import { + type Action, + action, + close, + type Color, + type Context, + copy, + detail, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + updateCommandMetadata, +} from "@backslash/api"; +import { + type Boot, + codeBlock, + duration, + type Entry, + FIELDS, + firstLine, + fullTime, + type Group, + groupEntries, + logLine, + mergeEntries, + orderGroups, + parseBoots, + parseJournal, + plural, + PRIORITIES, + shortTime, + shortUnit, + sourceArgs, +} from "../lib.ts"; +import { runInTerminal, shellLine } from "../terminal.ts"; + +interface Prefs { + terminal?: string; +} + +/** Entries read per journal, newest first: enough for a boot's problems without stalling. */ +const LIMIT = 3000; +/** Rows shown at most; repeated messages are already folded into one. */ +const MAX_ROWS = 400; + +interface Run { + out: string; + err: string; + ok: boolean; +} + +class NoJournal extends Error {} + +/** journalctl with its stderr, which carries the "you are not seeing all messages" hint. */ +async function journalctl(args: string[], signal?: AbortSignal): Promise<Run> { + try { + const { code, stdout, stderr } = await new Deno.Command("journalctl", { + args: ["--no-pager", ...args], + stdin: "null", + stdout: "piped", + stderr: "piped", + signal, + }).output(); + const dec = new TextDecoder(); + return { out: dec.decode(stdout), err: dec.decode(stderr), ok: code === 0 }; + } catch (err) { + if (err instanceof Deno.errors.NotFound) throw new NoJournal("journalctl not found"); + throw err; + } +} + +interface Load { + entries: Entry[]; + /** Only the user's own logs could be read. */ + partial: boolean; +} + +/** System (when readable) and user journals of one boot, within a priority range. */ +async function load(boot: string, priority: string, signal?: AbortSignal): Promise<Load> { + const args = ["-b", boot, "-p", priority, "-o", "json", "-n", String(LIMIT), `--output-fields=${FIELDS.join(",")}`]; + const [system, user] = await Promise.all([ + journalctl(args, signal), + journalctl(["--user", ...args], signal).catch((err) => { + if (err instanceof NoJournal) throw err; + return { out: "", err: "", ok: false }; + }), + ]); + const partial = /not seeing messages from other users and the system|insufficient permissions/i.test(system.err); + if (!system.ok && !user.ok && !system.out && /no such boot|specifies boot id|not found/i.test(system.err)) { + return { entries: [], partial }; + } + return { entries: mergeEntries(parseJournal(system.out), parseJournal(user.out)), partial }; +} + +// --- Views --------------------------------------------------------------------- + +function priorityTag(p: number): { text: string; color: Color } { + return { + text: PRIORITIES[p] ?? `Priority ${p}`, + color: p <= 2 ? "red" : p === 3 ? "red" : p === 4 ? "orange" : "gray", + }; +} + +function unitLogs(e: Entry, boot: string, prefs: Prefs) { + const src = sourceArgs(e); + const name = e.transport === "kernel" ? "Kernel" : e.unit ?? e.identifier ?? e.comm ?? "Logs"; + return detail({ + title: `${name} — last 200 lines`, + async markdown({ signal }) { + const run = await journalctl([...src, "-b", boot, "-n", "200", "-o", "short-iso"], signal); + const text = run.out.trim() || run.err.trim() || "No lines."; + return codeBlock(text); + }, + metadata: [ + meta.text("Source", name, { icon: Icons.server }), + meta.text("Boot", boot === "0" ? "Current" : boot === "-1" ? "Previous" : boot), + meta.text("Command", `journalctl ${shellLine([...src, "-b", boot, "-n", "200"])}`, { icon: Icons.terminal }), + ], + actions: [ + follow(e, prefs), + copy(`journalctl ${shellLine([...src, "-b", boot])}`, "Copy journalctl Command", { section: "Copy" }), + ], + }); +} + +function follow(e: Entry, prefs: Prefs): Action { + const argv = ["journalctl", "-f", "-n", "50", ...sourceArgs(e)]; + return action("Follow in Terminal", () => { + const term = runInTerminal(argv, prefs.terminal || undefined); + return close(`Following in ${term}`); + }, { icon: Icons.terminal, shortcut: "<Ctrl>t" }); +} + +function groupItem(g: Group, boot: string, prefs: Prefs): Item { + const e = g.latest; + const tag = priorityTag(g.priority); + const name = shortUnit(g.source); + const metadata: Metadata[] = [ + meta.tags("Priority", [tag]), + meta.text("Unit", e.unit ?? "–", { icon: Icons.server }), + ...(e.identifier && e.identifier !== shortUnit(e.unit ?? "") ? [meta.text("Identifier", e.identifier)] : []), + ...(e.userUnit ? [meta.text("Journal", "Your user session", { icon: Icons.person })] : []), + meta.separator(), + meta.text("Time", fullTime(e.time), { icon: Icons.clock }), + ...(g.count > 1 + ? [ + meta.text("Occurrences", `${g.count}×`, { icon: Icons.repeat }), + meta.text("First", fullTime(g.first)), + ] + : []), + ...(g.pids.length ? [meta.text(g.pids.length > 1 ? "PIDs" : "PID", g.pids.slice(0, 6).join(", "))] : []), + ...(e.exe ? [meta.text("Executable", e.exe, { icon: Icons.terminal })] : []), + ...(e.cmdline && e.cmdline !== e.exe ? [meta.text("Command line", e.cmdline)] : []), + ...(e.uid !== undefined ? [meta.text("User ID", e.uid)] : []), + ...(e.hostname ? [meta.text("Host", e.hostname)] : []), + ]; + return { + id: g.key, + title: firstLine(e.message) || "(empty message)", + icon: { source: g.priority <= 2 ? Icons.urgent : Icons.alert, tint: tag.color }, + section: name, + keywords: [g.source, e.identifier ?? "", e.comm ?? "", tag.text, String(e.pid ?? "")], + accessories: [ + ...(g.count > 1 ? [{ tag: `×${g.count}`, color: tag.color, tooltip: `${g.count} times this boot` }] : []), + { text: shortTime(e.time), tooltip: fullTime(e.time) }, + ], + detail: { markdown: codeBlock(e.message), metadata }, + actions: [ + action(`Show ${name} Logs`, () => unitLogs(e, boot, prefs), { icon: Icons.text }), + follow(e, prefs), + copy(e.message, "Copy Message", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(logLine(e), "Copy Log Line", { section: "Copy" }), + copy(`journalctl ${shellLine([...sourceArgs(e), "-b", boot])}`, "Copy journalctl Command", { + icon: Icons.terminal, + section: "Copy", + }), + ], + }; +} + +const permissionItem: Item = { + id: "permission", + title: "Only your own logs are shown", + subtitle: "Join the systemd-journal group to see the system's", + icon: { source: Icons.lock, tint: "yellow" }, + section: "Note", + detail: { + markdown: [ + "Your account can't read the system journal, so services, the kernel and other users are missing.", + "", + "Add yourself to the `systemd-journal` group (or `adm` on Debian and Ubuntu), then log out and back in:", + "", + codeBlock("sudo usermod -aG systemd-journal $USER"), + ].join("\n"), + }, + actions: [copy("sudo usermod -aG systemd-journal $USER", "Copy Command")], +}; + +const noJournalItem: Item = { + id: "no-journal", + title: "journalctl isn't available", + subtitle: "This system doesn't log with systemd's journal", + icon: { source: Icons.info, tint: "gray" }, + detail: { + markdown: [ + "System Logs reads systemd's journal with `journalctl`.", + "", + "Distributions without systemd (Alpine, Void, Gentoo with OpenRC, Devuan…) log to text files in `/var/log` instead.", + ].join("\n"), + }, +}; + +interface Counts { + errors: number; + warnings: number; +} +let counts: Counts = { errors: 0, warnings: 0 }; + +async function problems( + boot: string, + priority: string, + prefs: Prefs, + signal: AbortSignal, + subtitle: boolean, +): Promise<Item[]> { + let loaded: Load; + try { + loaded = await load(boot, priority, signal); + } catch (err) { + if (err instanceof NoJournal) return [noJournalItem]; + throw err; + } + const { entries, partial } = loaded; + counts = { + errors: entries.filter((e) => e.priority <= 3).length, + warnings: entries.filter((e) => e.priority === 4).length, + }; + if (subtitle) { + updateCommandMetadata({ + subtitle: counts.errors ? `${plural(counts.errors, "error")} this boot` : "No errors this boot", + }).catch(() => {}); + } + const groups = orderGroups(groupEntries(entries)).slice(0, MAX_ROWS); + return [...(partial ? [permissionItem] : []), ...groups.map((g) => groupItem(g, boot, prefs))]; +} + +function bootList(b: Boot, prefs: Prefs): ListSpec { + const boot = String(b.index); + return list({ + title: `Problems of ${bootName(b)}`, + placeholder: "Filter by message, unit, program…", + empty: { title: "No errors or warnings", icon: Icons.done }, + status: () => `${plural(counts.errors, "error")} · ${plural(counts.warnings, "warning")}`, + items: ({ signal }) => problems(b.id || boot, "0..4", prefs, signal, false), + }); +} + +function bootName(b: Boot): string { + if (b.index === 0) return "Current boot"; + if (b.index === -1) return "Previous boot"; + return `Boot of ${fullTime(b.first).slice(0, 16)}`; +} + +async function boots(prefs: Prefs, signal: AbortSignal): Promise<Item[]> { + let run: Run; + try { + run = await journalctl(["--list-boots", "-o", "json"], signal); + if (!run.ok || !run.out.trim().startsWith("[")) run = await journalctl(["--list-boots"], signal); + } catch (err) { + if (err instanceof NoJournal) return [noJournalItem]; + throw err; + } + return parseBoots(run.out).map((b) => ({ + id: `boot:${b.id}`, + title: bootName(b), + subtitle: `${fullTime(b.first).slice(0, 16)} → ${ + b.index === 0 + ? "now" + : fullTime(b.last).slice(0, 10) === fullTime(b.first).slice(0, 10) + ? fullTime(b.last).slice(11, 16) + : fullTime(b.last).slice(0, 16) + }`, + icon: { source: b.index === 0 ? Icons.power : Icons.history, tint: b.index === 0 ? "green" : "gray" }, + section: "Boots", + keywords: [b.id, String(b.index)], + accessories: [{ text: duration((b.index === 0 ? Date.now() : b.last) - b.first), tooltip: "Uptime" }, { + tag: String(b.index), + color: "gray", + }], + detail: { + metadata: [ + meta.text("Boot", bootName(b), { icon: Icons.power }), + meta.text("Started", fullTime(b.first), { icon: Icons.clock }), + meta.text(b.index === 0 ? "Last entry" : "Ended", fullTime(b.last)), + meta.text("Lasted", duration(b.last - b.first), { icon: Icons.timer }), + meta.separator(), + meta.text("Index", b.index), + meta.text("Boot ID", b.id), + ], + }, + actions: [ + action("Show Errors and Warnings", () => bootList(b, prefs), { icon: Icons.alert }), + action("Open Full Log in Terminal", () => { + const term = runInTerminal(["journalctl", "-b", b.id, "-e"], prefs.terminal || undefined); + return close(`Opened in ${term}`); + }, { icon: Icons.terminal }), + copy(b.id, "Copy Boot ID", { section: "Copy" }), + copy(`journalctl -b ${b.id} -p 0..4`, "Copy journalctl Command", { section: "Copy" }), + ], + })); +} + +export default (ctx: Context<Record<string, unknown>, Prefs>) => + list({ + placeholder: "Filter by message, unit, program…", + filter: { + options: [ + { value: "errors", title: "Errors" }, + { value: "warnings", title: "Warnings" }, + { value: "all", title: "All" }, + { value: "previous", title: "Previous Boot" }, + { value: "boots", title: "Boots" }, + ], + default: "errors", + }, + empty: { title: "Nothing to worry about", description: "No problems logged in this boot", icon: Icons.done }, + status: (items) => { + if (items.some((i) => i.section === "Boots")) return plural(items.length, "boot"); + const parts = []; + if (counts.errors) parts.push(plural(counts.errors, "error")); + if (counts.warnings) parts.push(plural(counts.warnings, "warning")); + return parts.join(" · ") || "No problems"; + }, + items: ({ filter, signal }) => { + const prefs = ctx.preferences; + switch (filter) { + case "warnings": + return problems("0", "4..4", prefs, signal, false); + case "all": + return problems("0", "0..4", prefs, signal, true); + case "previous": + return problems("-1", "0..4", prefs, signal, false); + case "boots": + return boots(prefs, signal); + default: + return problems("0", "0..3", prefs, signal, true); + } + }, + }); diff --git a/extensions/system-logs/lib.ts b/extensions/system-logs/lib.ts new file mode 100644 index 0000000..6796322 --- /dev/null +++ b/extensions/system-logs/lib.ts @@ -0,0 +1,284 @@ +// Pure helpers for System Logs: journalctl JSON parsing, grouping and formatting. + +/** journald priorities (syslog levels). */ +export const PRIORITIES = ["Emergency", "Alert", "Critical", "Error", "Warning", "Notice", "Info", "Debug"]; + +/** The fields asked from journalctl (`--output-fields`); cursor and timestamps always come along. */ +export const FIELDS = [ + "MESSAGE", + "PRIORITY", + "_SYSTEMD_UNIT", + "_SYSTEMD_USER_UNIT", + "UNIT", + "USER_UNIT", + "SYSLOG_IDENTIFIER", + "_PID", + "_EXE", + "_COMM", + "_CMDLINE", + "_TRANSPORT", + "_HOSTNAME", + "_BOOT_ID", + "_UID", + "CODE_FILE", + "CODE_FUNC", +]; + +export interface Entry { + cursor: string; + /** Milliseconds since the epoch. */ + time: number; + priority: number; + message: string; + /** systemd unit, user unit, or the identifier for processes outside units. */ + unit?: string; + userUnit: boolean; + identifier?: string; + pid?: number; + exe?: string; + comm?: string; + cmdline?: string; + transport?: string; + hostname?: string; + bootId?: string; + uid?: number; +} + +type Field = string | number[] | null | undefined | (string | number[])[]; + +/** journald gives binary or non-UTF-8 values as byte arrays, and repeated fields as arrays. */ +export function fieldText(v: Field): string | undefined { + if (v === null || v === undefined) return undefined; + if (typeof v === "string") return v; + if (Array.isArray(v) && v.every((x) => typeof x === "number")) { + return new TextDecoder().decode(new Uint8Array(v as number[])); + } + if (Array.isArray(v)) return fieldText((v as (string | number[])[])[0]); + return String(v); +} + +/** One `journalctl -o json` line. */ +export function parseEntry(line: string): Entry | undefined { + let raw: Record<string, Field>; + try { + raw = JSON.parse(line); + } catch { + return undefined; + } + if (!raw || typeof raw !== "object") return undefined; + const text = (k: string) => fieldText(raw[k])?.trim() || undefined; + const num = (k: string) => { + const n = Number(text(k)); + return text(k) !== undefined && Number.isFinite(n) ? n : undefined; + }; + const userUnit = text("_SYSTEMD_USER_UNIT"); + const sysUnit = text("_SYSTEMD_UNIT"); + const transport = text("_TRANSPORT"); + // A user's units all run under user@UID.service: the user unit says more. + let unit = userUnit ?? (sysUnit && !/^user@\d+\.service$/.test(sysUnit) ? sysUnit : undefined); + // systemd's own messages about a unit ("Failed to start nginx.service") belong to that unit. + const about = sysUnit === "init.scope" ? text("UNIT") : userUnit === "init.scope" ? text("USER_UNIT") : undefined; + if (about) unit = about; + return { + cursor: text("__CURSOR") ?? `${text("__REALTIME_TIMESTAMP")}:${text("MESSAGE")}`, + time: Math.floor(Number(text("__REALTIME_TIMESTAMP") ?? 0) / 1000), + priority: num("PRIORITY") ?? 6, + message: fieldText(raw.MESSAGE)?.replace(/\s+$/, "") ?? "", + unit, + userUnit: about ? userUnit === "init.scope" : !!userUnit, + identifier: text("SYSLOG_IDENTIFIER") ?? (transport === "kernel" ? "kernel" : undefined), + pid: num("_PID"), + exe: text("_EXE"), + comm: text("_COMM"), + cmdline: text("_CMDLINE"), + transport, + hostname: text("_HOSTNAME"), + bootId: text("_BOOT_ID"), + uid: num("_UID"), + }; +} + +export function parseJournal(out: string): Entry[] { + return out.split("\n").filter((l) => l.startsWith("{")).map(parseEntry).filter((e): e is Entry => !!e); +} + +/** Entries of several journalctl runs (system and --user), each once, newest first. */ +export function mergeEntries(...runs: Entry[][]): Entry[] { + const seen = new Map<string, Entry>(); + for (const run of runs) for (const e of run) if (!seen.has(e.cursor)) seen.set(e.cursor, e); + return [...seen.values()].sort((a, b) => b.time - a.time); +} + +/** Where an entry comes from, as a short name: the unit, else the program. */ +export function source(e: Entry): string { + if (e.transport === "kernel") return "Kernel"; + return e.unit ?? e.identifier ?? e.comm ?? "Unknown"; +} + +/** A unit name without its `.service` suffix. */ +export function shortUnit(name: string): string { + return name.replace(/\.service$/, ""); +} + +/** Repeated messages of a source, shown once with a count. */ +export interface Group { + key: string; + source: string; + /** The newest entry. */ + latest: Entry; + count: number; + first: number; + pids: number[]; + /** Highest severity among them (lowest number). */ + priority: number; +} + +/** Numbers that change from one repetition to the next (pids, addresses, counters) don't split a group. */ +export function messageKey(message: string): string { + return message.replace(/0x[0-9a-f]+/gi, "#").replace(/\d+/g, "#").slice(0, 300); +} + +export function groupEntries(entries: Entry[]): Group[] { + const groups = new Map<string, Group>(); + for (const e of entries) { + const src = source(e); + const key = `${src}\u0001${messageKey(e.message)}`; + const g = groups.get(key); + if (!g) { + groups.set(key, { + key, + source: src, + latest: e, + count: 1, + first: e.time, + pids: e.pid ? [e.pid] : [], + priority: e.priority, + }); + continue; + } + g.count++; + if (e.time > g.latest.time) g.latest = e; + g.first = Math.min(g.first, e.time); + g.priority = Math.min(g.priority, e.priority); + if (e.pid && !g.pids.includes(e.pid)) g.pids.push(e.pid); + } + return [...groups.values()]; +} + +/** Groups ordered by source, the sources with the most errors first, then newest first inside each. */ +export function orderGroups(groups: Group[]): Group[] { + const score = new Map<string, { errors: number; total: number; newest: number }>(); + for (const g of groups) { + const s = score.get(g.source) ?? { errors: 0, total: 0, newest: 0 }; + if (g.priority <= 3) s.errors += g.count; + s.total += g.count; + s.newest = Math.max(s.newest, g.latest.time); + score.set(g.source, s); + } + return [...groups].sort((a, b) => { + const sa = score.get(a.source)!; + const sb = score.get(b.source)!; + return sb.errors - sa.errors || sb.total - sa.total || sb.newest - sa.newest || + a.source.localeCompare(b.source) || b.latest.time - a.latest.time; + }); +} + +export interface Boot { + index: number; + id: string; + /** Milliseconds since the epoch. */ + first: number; + last: number; +} + +/** `journalctl --list-boots -o json` (systemd 251+), newest first. */ +export function parseBoots(out: string): Boot[] { + let raw: unknown; + try { + raw = JSON.parse(out); + } catch { + return parseBootsText(out); + } + if (!Array.isArray(raw)) return []; + return raw.map((b) => ({ + index: Number(b.index), + id: String(b.boot_id ?? ""), + first: Math.floor(Number(b.first_entry) / 1000), + last: Math.floor(Number(b.last_entry) / 1000), + })).filter((b) => b.id && Number.isFinite(b.index)).sort((a, b) => b.index - a.index); +} + +/** The plain `--list-boots` table of older systemd: `-1 3a5f… Tue 2026-10-27 08:01:12 CET—Tue 2026-10-27 23:40:02 CET`. */ +function parseBootsText(out: string): Boot[] { + const boots: Boot[] = []; + for (const line of out.split("\n")) { + const m = line.trim().match(/^(-?\d+)\s+([0-9a-f]{32})\s/); + const times = line.match(/\d{4}-\d\d-\d\d \d\d:\d\d:\d\d/g); + if (!m || !times || times.length < 2) continue; + const at = (t: string) => Date.parse(t.replace(" ", "T")); + boots.push({ index: Number(m[1]), id: m[2], first: at(times[0]), last: at(times[1]) }); + } + return boots.sort((a, b) => b.index - a.index); +} + +/** journalctl's arguments to show the logs of an entry's source. */ +export function sourceArgs(e: Entry): string[] { + if (e.transport === "kernel") return ["-k"]; + if (e.unit) return e.userUnit ? ["--user", "-u", e.unit] : ["-u", e.unit]; + if (e.identifier) return ["-t", e.identifier]; + if (e.comm) return [`_COMM=${e.comm}`]; + return []; +} + +// --------------------------------------------------------------------------- +// Formatting + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** "14:03" today, "Mon 14:03" this week, "12 Oct 14:03" before. */ +export function shortTime(ms: number, now = Date.now()): string { + const d = new Date(ms); + const n = new Date(now); + const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (d.toDateString() === n.toDateString()) return hm; + if (now - ms < 6 * 86400_000) return `${d.toLocaleDateString("en-GB", { weekday: "short" })} ${hm}`; + return `${d.getDate()} ${d.toLocaleDateString("en-GB", { month: "short" })} ${hm}`; +} + +export function fullTime(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${ + pad(d.getSeconds()) + }`; +} + +/** "3 h 12 min", "45 s". */ +export function duration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} h${m % 60 ? ` ${m % 60} min` : ""}`; + return `${Math.floor(h / 24)} days`; +} + +/** The first line, shortened for a list row. */ +export function firstLine(message: string, max = 140): string { + const line = message.split("\n").find((l) => l.trim()) ?? ""; + return line.length > max ? line.slice(0, max - 1) + "…" : line; +} + +/** "Oct 28 14:03:12 nginx.service[812]: message", like journalctl's short output. */ +export function logLine(e: Entry): string { + return `${fullTime(e.time)} ${source(e)}${e.pid ? `[${e.pid}]` : ""}: ${e.message}`; +} + +export function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? "" : "s"}`; +} + +/** Text safe inside a markdown code block. */ +export function codeBlock(text: string): string { + return "```\n" + text.replace(/```/g, "ˋˋˋ") + "\n```"; +} diff --git a/extensions/system-logs/lib_test.ts b/extensions/system-logs/lib_test.ts new file mode 100644 index 0000000..90e3734 --- /dev/null +++ b/extensions/system-logs/lib_test.ts @@ -0,0 +1,157 @@ +import { assert, assertEquals } from "@std/assert"; +import { + codeBlock, + duration, + fieldText, + firstLine, + groupEntries, + logLine, + mergeEntries, + messageKey, + orderGroups, + parseBoots, + parseEntry, + parseJournal, + source, + sourceArgs, +} from "./lib.ts"; + +const T = 1761645600000000; // µs +const line = (o: Record<string, unknown>) => JSON.stringify(o); + +// journalctl -o json from systemd 255, trimmed to the asked fields. +const JOURNAL = [ + line({ + __CURSOR: "s=1;i=10", + __REALTIME_TIMESTAMP: String(T), + PRIORITY: "3", + MESSAGE: "nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + _SYSTEMD_UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "nginx", + _PID: "2213", + _EXE: "/usr/sbin/nginx", + _COMM: "nginx", + }), + line({ + __CURSOR: "s=1;i=11", + __REALTIME_TIMESTAMP: String(T - 60_000_000), + PRIORITY: "3", + MESSAGE: "nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + _SYSTEMD_UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "nginx", + _PID: "2201", + }), + line({ + __CURSOR: "s=1;i=12", + __REALTIME_TIMESTAMP: String(T - 30_000_000), + PRIORITY: "3", + MESSAGE: "Failed to start nginx.service - A high performance web server.", + _SYSTEMD_UNIT: "init.scope", + UNIT: "nginx.service", + SYSLOG_IDENTIFIER: "systemd", + _PID: "1", + }), + line({ + __CURSOR: "s=1;i=13", + __REALTIME_TIMESTAMP: String(T - 120_000_000), + PRIORITY: "4", + MESSAGE: [87, 105, 70, 105, 32, 255], + _TRANSPORT: "kernel", + }), + line({ + __CURSOR: "s=1;i=14", + __REALTIME_TIMESTAMP: String(T - 10_000_000), + PRIORITY: "3", + MESSAGE: + "JS ERROR: TypeError: this._notification is null\n_updateState@resource:///org/gnome/shell/ui/messageTray.js", + _SYSTEMD_UNIT: "user@1000.service", + _SYSTEMD_USER_UNIT: "org.gnome.Shell@wayland.service", + SYSLOG_IDENTIFIER: "gnome-shell", + _PID: "1650", + }), + "Hint: not json", + "{broken", +].join("\n"); + +Deno.test("parseEntry reads fields, timestamps and byte-array messages", () => { + const entries = parseJournal(JOURNAL); + assertEquals(entries.length, 5); + const [nginx, , systemd, kernel, shell] = entries; + assertEquals(nginx.time, T / 1000); + assertEquals([nginx.priority, nginx.unit, nginx.pid, nginx.exe], [3, "nginx.service", 2213, "/usr/sbin/nginx"]); + assertEquals(systemd.unit, "nginx.service"); + assertEquals(kernel.message, "WiFi �"); + assertEquals(source(kernel), "Kernel"); + assertEquals([shell.unit, shell.userUnit], ["org.gnome.Shell@wayland.service", true]); + assertEquals(parseEntry("[]")?.message, ""); +}); + +Deno.test("fieldText handles strings, bytes, repeated fields and nulls", () => { + assertEquals(fieldText("x"), "x"); + assertEquals(fieldText([104, 105]), "hi"); + assertEquals(fieldText(["a", "b"]), "a"); + assertEquals(fieldText(null), undefined); +}); + +Deno.test("mergeEntries drops duplicates seen by both journals and sorts newest first", () => { + const sys = parseJournal(JOURNAL); + const merged = mergeEntries(sys, sys.slice(0, 2)); + assertEquals(merged.length, 5); + assertEquals(merged.map((e) => e.cursor), ["s=1;i=10", "s=1;i=14", "s=1;i=12", "s=1;i=11", "s=1;i=13"]); +}); + +Deno.test("groupEntries folds repeats; orderGroups puts noisy units first", () => { + const groups = orderGroups(groupEntries(mergeEntries(parseJournal(JOURNAL)))); + assertEquals(groups.map((g) => [g.source, g.count]), [ + ["nginx.service", 2], + ["nginx.service", 1], + ["org.gnome.Shell@wayland.service", 1], + ["Kernel", 1], + ]); + assertEquals(groups[0].pids, [2213, 2201]); + assertEquals(groups[0].latest.pid, 2213); + assertEquals(groups[0].first, T / 1000 - 60_000); + assertEquals(messageKey("Killed process 4242 at 0xffff12"), "Killed process # at #"); +}); + +Deno.test("sourceArgs picks the right journalctl filter", () => { + const [nginx, , , kernel, shell] = parseJournal(JOURNAL); + assertEquals(sourceArgs(nginx), ["-u", "nginx.service"]); + assertEquals(sourceArgs(kernel), ["-k"]); + assertEquals(sourceArgs(shell), ["--user", "-u", "org.gnome.Shell@wayland.service"]); + assertEquals(sourceArgs(parseEntry(line({ __CURSOR: "c", SYSLOG_IDENTIFIER: "cron" }))!), ["-t", "cron"]); +}); + +Deno.test("parseBoots reads JSON and the older text table", () => { + const json = parseBoots(JSON.stringify([ + { index: -1, boot_id: "aaaabbbbccccddddeeeeffff00001111", first_entry: T - 86400e6, last_entry: T - 50000e6 }, + { index: 0, boot_id: "3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d", first_entry: T - 7200e6, last_entry: T }, + ])); + assertEquals(json.map((b) => b.index), [0, -1]); + assertEquals(json[0].first, T / 1000 - 7200e3); + const text = parseBoots( + "IDX BOOT ID FIRST ENTRY LAST ENTRY\n" + + " -1 aaaabbbbccccddddeeeeffff00001111 Mon 2026-10-27 08:01:12 CET Mon 2026-10-27 23:40:02 CET\n" + + " 0 3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d Tue 2026-10-28 08:00:59 CET Tue 2026-10-28 10:20:00 CET\n", + ); + assertEquals(text.map((b) => [b.index, b.id]), [[0, "3a5f0c2d9e8b4f7a8c1d2e3f4a5b6c7d"], [ + -1, + "aaaabbbbccccddddeeeeffff00001111", + ]]); + assert(Number.isFinite(text[0].first)); + assertEquals(parseBoots("No journal files were found."), []); +}); + +Deno.test("formatting helpers", () => { + assertEquals(duration(45_000), "45 s"); + assertEquals(duration(3 * 3600_000 + 12 * 60_000), "3 h 12 min"); + assertEquals(duration(5 * 86400_000), "5 days"); + assertEquals(firstLine("\n \nfirst\nsecond"), "first"); + assertEquals(firstLine("x".repeat(200), 10), "xxxxxxxxx…"); + assert( + logLine(parseJournal(JOURNAL)[0]).endsWith( + "nginx.service[2213]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)", + ), + ); + assertEquals(codeBlock("a ``` b"), "```\na ˋˋˋ b\n```"); +}); diff --git a/extensions/system-logs/terminal.ts b/extensions/system-logs/terminal.ts new file mode 100644 index 0000000..b45f9ff --- /dev/null +++ b/extensions/system-logs/terminal.ts @@ -0,0 +1,77 @@ +// Running a command in the user's terminal emulator (same detection as the Code Projects extension). + +/** Tried in this order when no terminal is set in the preferences. */ +export const TERMINALS = [ + "kitty", + "alacritty", + "wezterm", + "foot", + "ghostty", + "ptyxis", + "kgx", + "gnome-terminal", + "konsole", + "xfce4-terminal", + "tilix", + "xterm", +]; + +const quote = (arg: string) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`; + +/** A command line as a shell would read it back. */ +export const shellLine = (argv: string[]) => argv.map(quote).join(" "); + +/** The arguments that make `terminal` run `argv` instead of a shell. */ +export function terminalArgs(terminal: string, argv: string[]): string[] { + switch (terminal) { + case "wezterm": + return ["start", "--", ...argv]; + case "kitty": + case "foot": + return argv; + case "ptyxis": + case "kgx": + case "gnome-terminal": + return ["--", ...argv]; + case "xfce4-terminal": + return ["-x", ...argv]; + case "tilix": + return ["-e", shellLine(argv)]; + default: // alacritty, ghostty, konsole, xterm and most others + return ["-e", ...argv]; + } +} + +/** Starts `argv` in a new terminal window that outlives the command. */ +export function runInTerminal(argv: string[], preferred?: string): string { + for (const term of preferred ? [preferred] : TERMINALS) { + try { + const child = new Deno.Command(term, { + args: terminalArgs(term, argv), + stdin: "null", + stdout: "null", + stderr: "null", + }).spawn(); + child.unref(); + return term; + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + throw new Error( + preferred + ? `Terminal "${preferred}" not found: change it in the extension's preferences` + : "No terminal found: set one in the extension's preferences", + ); +} + +/** Starts a desktop program that outlives the command; false when it is not installed. */ +export function launch(program: string, args: string[] = []): boolean { + try { + new Deno.Command(program, { args, stdin: "null", stdout: "null", stderr: "null" }).spawn().unref(); + return true; + } catch (err) { + if (err instanceof Deno.errors.NotFound) return false; + throw err; + } +} diff --git a/extensions/text-to-speech/backslash.toml b/extensions/text-to-speech/backslash.toml new file mode 100644 index 0000000..d888659 --- /dev/null +++ b/extensions/text-to-speech/backslash.toml @@ -0,0 +1,95 @@ +[extension] +id = "text-to-speech" +title = "Text to Speech" +description = "Read the clipboard, the selection or any text aloud with Piper, eSpeak NG, Speech Dispatcher or Festival" +version = "1.0.0" +author = "Backslash" +icon = "bs-speaker" +license = "MIT" + +# Speech runs in a systemd user unit (systemd-run), so it goes on once the +# launcher is hidden and "Stop Speaking" stops the unit. The text is written +# to ~/.cache/backslash/tts first. Piper voices are read from +# ~/.local/share/piper; aplay, paplay or pw-play play Piper's audio inside the +# unit. wl-paste, xclip and xsel read the selected text. +[permissions] +run = ["systemd-run", "systemctl", "piper", "espeak-ng", "spd-say", "festival", "wl-paste", "xclip", "xsel"] +read = ["~/.cache/backslash/tts", "~/.local/share/piper", "/etc/os-release"] +write = ["~/.cache/backslash/tts"] +env = ["HOME", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "PULSE_SERVER", "PIPEWIRE_REMOTE"] +clipboard-read = true + +[[commands]] +name = "speak" +title = "Text to Speech" +description = "Type or pick a text to read aloud: clipboard, selection, the last one" +icon = "bs-speaker" +keywords = ["speak", "say", "read aloud", "tts", "voice", "lire", "lire à voix haute", "synthèse vocale", "parler", "voix"] + +[[commands]] +name = "speak-clipboard" +title = "Speak Clipboard" +description = "Reads the copied text aloud" +icon = "bs-paste" +mode = "no-view" +keywords = ["speak", "clipboard", "read aloud", "tts", "presse-papiers", "lire", "voix"] + +[[commands]] +name = "speak-selection" +title = "Speak Selected Text" +description = "Reads the text selected with the mouse" +icon = "bs-text" +mode = "no-view" +keywords = ["speak", "selection", "sélection", "read aloud", "tts", "lire", "voix"] + +[[commands]] +name = "speak-text" +title = "Speak Text" +description = "Reads what you type after the command" +icon = "bs-speaker" +mode = "no-view" +keywords = ["say", "speak", "tts", "dire", "parler"] +[[commands.arguments]] +name = "text" +placeholder = "Text to read" +required = true + +[[commands]] +name = "stop-speaking" +title = "Stop Speaking" +description = "Stops reading aloud" +icon = "bs-stop" +mode = "no-view" +keywords = ["stop", "silence", "tais-toi", "arrêter", "tts", "speech"] + +[[commands]] +name = "voices" +title = "Text to Speech Voices" +description = "Pick and preview the voice of each engine" +icon = "bs-settings" +keywords = ["voices", "voix", "tts", "piper", "espeak", "language", "langue"] + +[[preferences]] +name = "engine" +title = "Engine" +description = "auto (Piper with a voice, else eSpeak NG, Speech Dispatcher, Festival), piper, espeak-ng, spd-say or festival" +default = "auto" + +[[preferences]] +name = "speed" +title = "Speed (%)" +description = "100 is normal; 150 reads half as fast again" +type = "number" +default = 100 + +[[preferences]] +name = "voice" +title = "Voice" +description = "Optional: an espeak voice (fr, en-us), a Speech Dispatcher voice or a Piper model path. Empty: the voice chosen in Voices, else one for the text's language" +default = "" + +[[preferences]] +name = "piperModels" +title = "Piper voices folder" +description = "Where the .onnx voices are; it must be inside ~/.local/share/piper" +default = "~/.local/share/piper" diff --git a/extensions/text-to-speech/commands/speak-clipboard.ts b/extensions/text-to-speech/commands/speak-clipboard.ts new file mode 100644 index 0000000..e63158e --- /dev/null +++ b/extensions/text-to-speech/commands/speak-clipboard.ts @@ -0,0 +1,10 @@ +import { clipboard, run } from "@backslash/api"; +import { type Prefs } from "../system.ts"; +import { say } from "../ui.ts"; + +// Hides the launcher and reads the clipboard aloud in the background. +export default run(async ({ preferences }) => { + const text = await clipboard.read(); + if (!text.trim()) throw new Error("The clipboard has no text"); + return await say(text, preferences as Prefs); +}); diff --git a/extensions/text-to-speech/commands/speak-selection.ts b/extensions/text-to-speech/commands/speak-selection.ts new file mode 100644 index 0000000..214403b --- /dev/null +++ b/extensions/text-to-speech/commands/speak-selection.ts @@ -0,0 +1,10 @@ +import { run } from "@backslash/api"; +import { type Prefs, selection } from "../system.ts"; +import { say } from "../ui.ts"; + +// The text selected with the mouse (primary selection), read aloud. +export default run(async ({ preferences }) => { + const text = await selection(); + if (!text.trim()) throw new Error("No text is selected (needs wl-clipboard on Wayland, xclip or xsel on X11)"); + return await say(text, preferences as Prefs); +}); diff --git a/extensions/text-to-speech/commands/speak-text.ts b/extensions/text-to-speech/commands/speak-text.ts new file mode 100644 index 0000000..37571c9 --- /dev/null +++ b/extensions/text-to-speech/commands/speak-text.ts @@ -0,0 +1,10 @@ +import { run } from "@backslash/api"; +import { type Prefs } from "../system.ts"; +import { say } from "../ui.ts"; + +// "Speak Text Hello world": read at once, no view. +export default run<{ text?: string }>(async ({ args, preferences }) => { + const text = String(args.text ?? ""); + if (!text.trim()) throw new Error("Type the text after the command"); + return await say(text, preferences as Prefs); +}); diff --git a/extensions/text-to-speech/commands/speak.ts b/extensions/text-to-speech/commands/speak.ts new file mode 100644 index 0000000..b052c4f --- /dev/null +++ b/extensions/text-to-speech/commands/speak.ts @@ -0,0 +1,57 @@ +import { action, clipboard, type Context, Icons, type Item, list, updateCommandMetadata } from "@backslash/api"; +import { ENGINE_NAMES, preview } from "../lib.ts"; +import { last, selection, speaking } from "../system.ts"; +import { engineFor, installItem, prefsOf, stopAction, textItem } from "../ui.ts"; +import { voicesList } from "./voices.ts"; + +let status = ""; + +export default (ctx: Context) => { + const prefs = prefsOf(ctx); + return list({ + placeholder: "Type something to read aloud…", + status: () => status, + async search(query) { + const [{ engine, piperWithoutModel }, busy] = await Promise.all([engineFor(prefs), speaking()]); + if (!engine) { + status = "No speech engine"; + return [await installItem(piperWithoutModel)]; + } + status = `${ENGINE_NAMES[engine]}${busy ? " · speaking" : ""}`; + if (!busy) updateCommandMetadata({ subtitle: null }).catch(() => {}); + const items: Item[] = []; + if (busy) { + items.push({ + id: "stop", + title: "Stop Speaking", + subtitle: "Reading in the background", + icon: { source: Icons.stop, tint: "red" }, + section: "Now", + actions: [stopAction()], + }); + } + if (query.trim()) { + items.push(textItem("typed", "Speak Text", query, prefs, "Speak", Icons.text)); + return items; + } + const [clip, sel, prev] = await Promise.all([clipboard.read().catch(() => ""), selection(), last()]); + if (clip.trim()) items.push(textItem("clipboard", "Speak Clipboard", clip, prefs, "Speak", Icons.paste)); + if (sel.trim() && sel.trim() !== clip.trim()) { + items.push(textItem("selection", "Speak Selected Text", sel, prefs, "Speak", Icons.text)); + } + if (prev && prev.text.trim() !== clip.trim()) { + const item = textItem("last", "Speak Again", prev.text, prefs, "Recent", Icons.history); + items.push({ ...item, subtitle: preview(prev.text, 70) }); + } + items.push({ + id: "voices", + title: "Voices", + subtitle: "Choose the default voice of each engine", + icon: { source: Icons.settings, tint: "gray" }, + section: "Settings", + actions: [action("Show Voices", () => voicesList(ctx), { icon: Icons.speaker })], + }); + return items; + }, + }); +}; diff --git a/extensions/text-to-speech/commands/stop-speaking.ts b/extensions/text-to-speech/commands/stop-speaking.ts new file mode 100644 index 0000000..15974f3 --- /dev/null +++ b/extensions/text-to-speech/commands/stop-speaking.ts @@ -0,0 +1,9 @@ +import { run, updateCommandMetadata } from "@backslash/api"; +import { speaking, stop } from "../system.ts"; + +export default run(async () => { + const busy = await speaking(); + await stop(); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return busy ? "Stopped" : "Nothing was being read"; +}); diff --git a/extensions/text-to-speech/commands/voices.ts b/extensions/text-to-speech/commands/voices.ts new file mode 100644 index 0000000..34f6be5 --- /dev/null +++ b/extensions/text-to-speech/commands/voices.ts @@ -0,0 +1,35 @@ +import { type Context, Icons, type Item, list, type ListSpec } from "@backslash/api"; +import { ENGINE_NAMES } from "../lib.ts"; +import { chooseVoice, chosenVoices, voices } from "../system.ts"; +import { engineFor, installItem, prefsOf, voiceItem } from "../ui.ts"; + +export function voicesList(ctx: Context): ListSpec { + const prefs = prefsOf(ctx); + return list({ + title: "Voices", + placeholder: "Filter voices by name or language (fr, en-us)…", + status: (items) => + `${items.length} voices${prefs.voice ? ` · the “voice” preference overrides: ${prefs.voice}` : ""}`, + async items() { + const { available, piperWithoutModel } = await engineFor(prefs); + if (!available.size) return [await installItem(piperWithoutModel)]; + const chosen = await chosenVoices(); + const all = await Promise.all([...available].map((e) => voices(e, prefs))); + const items: Item[] = all.flat().map((v) => voiceItem(v, chosen[v.engine] === v.id, () => chooseVoice(v), prefs)); + for (const e of available) { + if (e === "festival") { + items.push({ + id: "festival", + title: "Festival's default voice", + subtitle: "Festival picks its voice itself", + icon: { source: Icons.speaker, tint: "gray" }, + section: ENGINE_NAMES.festival, + }); + } + } + return items; + }, + }); +} + +export default (ctx: Context) => voicesList(ctx); diff --git a/extensions/text-to-speech/lib.ts b/extensions/text-to-speech/lib.ts new file mode 100644 index 0000000..453979d --- /dev/null +++ b/extensions/text-to-speech/lib.ts @@ -0,0 +1,299 @@ +// Speech engines, their voices and the background script. Pure, tested in lib_test.ts. + +export type Engine = "piper" | "espeak-ng" | "spd-say" | "festival"; + +export const ENGINES: Engine[] = ["piper", "espeak-ng", "spd-say", "festival"]; + +export const ENGINE_NAMES: Record<Engine, string> = { + piper: "Piper", + "espeak-ng": "eSpeak NG", + "spd-say": "Speech Dispatcher", + festival: "Festival", +}; + +/** The engine preference: "auto" or one of the engines. */ +export function parseEngine(pref: string | undefined): Engine | "auto" { + const p = (pref ?? "").trim().toLowerCase().replace(/^espeak$/, "espeak-ng").replace( + /^speech-dispatcher$/, + "spd-say", + ); + return (ENGINES as string[]).includes(p) ? p as Engine : "auto"; +} + +/** The first engine that is installed (and, for Piper, has a voice model). */ +export function pickEngine(pref: Engine | "auto", available: Set<Engine>): Engine | undefined { + if (pref !== "auto") return available.has(pref) ? pref : undefined; + return ENGINES.find((e) => available.has(e)); +} + +// --------------------------------------------------------------------------- +// Voices + +export interface Voice { + engine: Engine; + /** What the engine takes: an espeak voice name, a model path, a dispatcher voice. */ + id: string; + name: string; + /** "fr", "en-us"… */ + language: string; + gender?: string; + quality?: string; + /** Piper: the sample rate of the model. */ + sampleRate?: number; +} + +/** + * `espeak-ng --voices`: + * ``` + * Pty Language Age/Gender VoiceName File Other Languages + * 5 af --/M Afrikaans gmw/af + * 2 en-gb --/M English_(Great_Britain) gmw/en (en 2) + * ``` + */ +export function parseEspeakVoices(out: string): Voice[] { + return out.split("\n").slice(1).flatMap((line): Voice[] => { + const m = line.trim().match(/^\d+\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/); + if (!m) return []; + const gender = m[2].split("/")[1]; + return [{ + engine: "espeak-ng", + id: m[1], + name: m[3].replace(/_/g, " "), + language: m[1].toLowerCase(), + gender: gender === "M" ? "Male" : gender === "F" ? "Female" : undefined, + }]; + }); +} + +/** `spd-say -L`: "NAME LANGUAGE VARIANT" after a header. */ +export function parseSpdVoices(out: string): Voice[] { + return out.split("\n").flatMap((line): Voice[] => { + const m = line.trim().match(/^(\S+)\s+([a-z]{2,3}(?:[-_][A-Za-z0-9]+)*)\s+(\S+)$/); + if (!m || m[1] === "NAME") return []; + return [{ + engine: "spd-say", + id: m[1], + name: m[1].replace(/[_+]/g, " "), + language: m[2].toLowerCase().replace("_", "-"), + }]; + }); +} + +/** A Piper model from its `.onnx.json` config, next to `<name>.onnx`. */ +export function piperVoice(modelPath: string, configJson: string | undefined): Voice { + const file = modelPath.split("/").pop()!.replace(/\.onnx$/, ""); + // fr_FR-siwis-medium + const m = file.match(/^([a-z]{2,3})_([A-Z]{2})-(.+?)-(x_low|low|medium|high)$/); + let cfg: { audio?: { sample_rate?: number; quality?: string }; language?: { code?: string; name_english?: string } } = + {}; + try { + cfg = configJson ? JSON.parse(configJson) : {}; + } catch { + // Defaults below. + } + const lang = (cfg.language?.code ?? (m ? `${m[1]}_${m[2]}` : "en")).toLowerCase().replace("_", "-"); + const speaker = m?.[3] ?? file; + return { + engine: "piper", + id: modelPath, + name: `${speaker.replace(/[_-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}${ + cfg.language?.name_english ? ` · ${cfg.language.name_english}` : "" + }`, + language: lang, + quality: cfg.audio?.quality ?? m?.[4]?.replace("_", " "), + sampleRate: cfg.audio?.sample_rate ?? 22050, + }; +} + +// --------------------------------------------------------------------------- +// Language guess + +const STOPWORDS: Record<string, string[]> = { + en: ["the", "and", "is", "are", "you", "that", "with", "for", "this", "have", "not", "was", "of", "to"], + fr: ["le", "la", "les", "et", "est", "une", "des", "pour", "que", "qui", "dans", "pas", "vous", "sur", "avec", "du"], + de: ["der", "die", "das", "und", "ist", "nicht", "ein", "eine", "mit", "sie", "ich", "auf", "für", "den"], + es: ["el", "los", "las", "y", "es", "una", "por", "que", "con", "para", "del", "como", "pero", "muy"], + it: ["il", "gli", "e", "è", "una", "per", "che", "con", "non", "della", "sono", "come", "anche"], + pt: ["o", "os", "as", "e", "é", "uma", "para", "que", "com", "não", "do", "da", "em", "você"], + nl: ["de", "het", "en", "is", "een", "van", "niet", "met", "voor", "dat", "zijn", "ook"], +}; + +/** The likely language of a text ("fr", "en"…), or undefined when it's too short to tell. */ +export function guessLanguage(text: string): string | undefined { + const words = text.toLowerCase().match(/[\p{L}']+/gu) ?? []; + if (words.length < 3) return undefined; + let best: string | undefined; + let bestScore = 0; + for (const [lang, list] of Object.entries(STOPWORDS)) { + const set = new Set(list); + let score = words.filter((w) => set.has(w.replace(/^[ldjmnst]'/, ""))).length; + if (lang === "fr") { + score += (text.match(/[àâçéèêëîïôûùœ]/gi) ?? []).length * 0.3 + (text.match(/\b[ldjmnst]'/gi) ?? []).length; + } + if (lang === "de") score += (text.match(/[äöüß]/gi) ?? []).length * 0.5; + if (lang === "es") score += (text.match(/[ñ¿¡]/gi) ?? []).length; + if (score > bestScore) [best, bestScore] = [lang, score]; + } + return bestScore >= 1 ? best : undefined; +} + +/** The chosen voice, else one for the text's language, else the first one. */ +export function voiceFor(voices: Voice[], chosen: string | undefined, language: string | undefined): Voice | undefined { + if (chosen) { + const v = voices.find((x) => x.id === chosen) ?? voices.find((x) => x.name.toLowerCase() === chosen.toLowerCase()); + if (v) return v; + } + if (language) { + const base = (l: string) => l.split(/[-_]/)[0]; + const exact = voices.filter((v) => base(v.language) === language); + if (exact.length) { + // espeak: "fr" before "fr-be"; piper: the best quality first. + const rank = (v: Voice) => + (v.language === language ? 0 : 1) + ({ high: 0, medium: 0.1, low: 0.2, "x low": 0.3 }[v.quality ?? ""] ?? 0.15); + return [...exact].sort((a, b) => rank(a) - rank(b))[0]; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Text + +/** Readable text: no URLs spelled letter by letter, no markdown marks, collapsed spaces. */ +export function cleanText(text: string): string { + return text + .replace(/```[\s\S]*?```/g, " ") + .replace(/\[([^\]]+)\]\((?:https?:\/\/)[^)]+\)/g, "$1") + .replace(/https?:\/\/(?:www\.)?([^/\s]+)\S*/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/(\*\*|__|`)/g, "") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** About 160 words a minute at normal speed. */ +export function duration(text: string, speed = 1): number { + const words = (text.match(/[\p{L}\p{N}]+/gu) ?? []).length; + return Math.max(1, Math.round((words / 160) * 60 / Math.max(0.25, speed))); +} + +export function clock(seconds: number): string { + const m = Math.floor(seconds / 60); + return `${m}:${String(seconds % 60).padStart(2, "0")}`; +} + +export const preview = (text: string, max = 60) => { + const line = text.replace(/\s+/g, " ").trim(); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +}; + +/** The speed preference in percent (100 = normal), kept within what engines handle. */ +export const speedOf = (pref: unknown) => Math.max(0.3, Math.min(3, (Number(pref) || 100) / 100)); + +// --------------------------------------------------------------------------- +// Commands + +/** Single-quotes for sh. */ +export const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`; + +export interface SpeakOptions { + engine: Engine; + /** The file holding the text. */ + file: string; + voice?: Voice; + language?: string; + /** 1 = normal. */ + speed: number; + /** For Piper's WAV fallback. */ + cache: string; +} + +/** The shell script that reads `file` aloud and exits when done. */ +export function speakScript(o: SpeakOptions): string { + const f = q(o.file); + switch (o.engine) { + case "espeak-ng": { + const voice = o.voice?.id ?? o.language; + const wpm = Math.round(175 * o.speed); + return `exec espeak-ng ${voice ? `-v ${q(voice)} ` : ""}-s ${wpm} -f ${f}`; + } + case "spd-say": { + const rate = Math.round(Math.max(-100, Math.min(100, (o.speed - 1) * 100))); + const voice = o.voice ? `-y ${q(o.voice.id)} ` : ""; + const lang = o.voice?.language ?? o.language; + return `exec spd-say -w -e -r ${rate} ${voice}${lang ? `-l ${q(lang.split("-")[0])} ` : ""}< ${f}`; + } + case "festival": + return `exec festival --tts ${f}`; + case "piper": { + const model = q(o.voice!.id); + const rate = o.voice?.sampleRate ?? 22050; + const scale = (1 / o.speed).toFixed(2); + const wav = q(`${o.cache}/speech.wav`); + return [ + `if command -v aplay >/dev/null 2>&1; then`, + ` piper --model ${model} --length_scale ${scale} --output-raw < ${f} | aplay -q -r ${rate} -f S16_LE -t raw -c 1 -`, + `elif command -v paplay >/dev/null 2>&1; then`, + ` piper --model ${model} --length_scale ${scale} --output-raw < ${f} | paplay --raw --rate=${rate} --format=s16le --channels=1`, + `else`, + ` piper --model ${model} --length_scale ${scale} --output_file ${wav} < ${f} && pw-play ${wav}`, + `fi`, + ].join("\n"); + } + } +} + +export const UNIT = "backslash-text-to-speech"; + +export function systemdRunArgs(script: string, env: Record<string, string | undefined> = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${UNIT}`, + "--description=Backslash: text to speech", + "--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 INSTALL: Record<Family, string> = { + arch: "sudo pacman -S espeak-ng", + debian: "sudo apt install espeak-ng", + fedora: "sudo dnf install espeak-ng", + suse: "sudo zypper install espeak-ng", +}; + +const FAMILY_NAMES: Record<Family, string> = { + arch: "Arch", + debian: "Debian, Ubuntu", + fedora: "Fedora", + suse: "openSUSE", +}; + +export function installMarkdown(family?: Family): string { + const families = family ? [family] : (Object.keys(INSTALL) as Family[]); + return [ + "Backslash reads text aloud with **eSpeak NG** (small, every language), **Piper** (natural neural voices), **Speech Dispatcher** or **Festival**. The quickest start:", + ...families.map((f) => `**${FAMILY_NAMES[f]}**\n\n\`${INSTALL[f]}\``), + "For natural voices, install Piper (`pipx install piper-tts`, or `piper-tts-bin` from the AUR) and put a voice (`.onnx` and its `.onnx.json`) from huggingface.co/rhasspy/piper-voices in `~/.local/share/piper`.", + ].join("\n\n"); +} diff --git a/extensions/text-to-speech/lib_test.ts b/extensions/text-to-speech/lib_test.ts new file mode 100644 index 0000000..78efe14 --- /dev/null +++ b/extensions/text-to-speech/lib_test.ts @@ -0,0 +1,140 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { + cleanText, + clock, + duration, + guessLanguage, + installMarkdown, + osFamily, + parseEngine, + parseEspeakVoices, + parseSpdVoices, + pickEngine, + piperVoice, + speakScript, + speedOf, + systemdRunArgs, + voiceFor, +} from "./lib.ts"; + +const ESPEAK = `Pty Language Age/Gender VoiceName File Other Languages + 5 af --/M Afrikaans gmw/af + 2 en-gb --/M English_(Great_Britain) gmw/en (en 2) + 2 en-us --/M English_(America) gmw/en-US (en 3) + 5 fr-be --/M French_(Belgium) roa/fr-BE (fr 8) + 5 fr --/M French_(France) roa/fr (fr 5) + 5 de --/F German gmw/de +`; + +const SPD = ` NAME LANGUAGE VARIANT + afrikaans af none + english-us en-US none + french fr none +`; + +const PIPER_CONFIG = JSON.stringify({ + audio: { sample_rate: 22050, quality: "medium" }, + espeak: { voice: "fr" }, + language: { code: "fr_FR", family: "fr", region: "FR", name_native: "Français", name_english: "French" }, + num_speakers: 1, +}); + +Deno.test("engines", () => { + assertEquals(parseEngine("espeak"), "espeak-ng"); + assertEquals(parseEngine("Speech-Dispatcher"), "spd-say"); + assertEquals(parseEngine("whatever"), "auto"); + assertEquals(pickEngine("auto", new Set(["festival", "espeak-ng"])), "espeak-ng"); + assertEquals(pickEngine("auto", new Set(["piper", "espeak-ng"])), "piper"); + assertEquals(pickEngine("piper", new Set(["espeak-ng"])), undefined); + assertEquals(pickEngine("auto", new Set()), undefined); +}); + +Deno.test("voices", () => { + const es = parseEspeakVoices(ESPEAK); + assertEquals(es.length, 6); + assertEquals(es[1], { + engine: "espeak-ng", + id: "en-gb", + name: "English (Great Britain)", + language: "en-gb", + gender: "Male", + }); + assertEquals(es[5].gender, "Female"); + const spd = parseSpdVoices(SPD); + assertEquals(spd.map((v) => [v.id, v.language]), [["afrikaans", "af"], ["english-us", "en-us"], ["french", "fr"]]); + const p = piperVoice("/home/ada/.local/share/piper/fr_FR-siwis-medium.onnx", PIPER_CONFIG); + assertEquals([p.name, p.language, p.quality, p.sampleRate], ["Siwis · French", "fr-fr", "medium", 22050]); + const bare = piperVoice("/m/en_US-lessac-high.onnx", undefined); + assertEquals([bare.name, bare.language, bare.quality, bare.sampleRate], ["Lessac", "en-us", "high", 22050]); +}); + +Deno.test("language guess and voice choice", () => { + assertEquals(guessLanguage("Bonjour, je voudrais réserver une table pour ce soir."), "fr"); + assertEquals(guessLanguage("The quick brown fox jumps over the lazy dog and that is it."), "en"); + assertEquals(guessLanguage("Ich bin nicht sicher, ob das stimmt."), "de"); + assertEquals(guessLanguage("¿Dónde está la estación de tren? Es muy tarde."), "es"); + assertEquals(guessLanguage("OK"), undefined); + const es = parseEspeakVoices(ESPEAK); + assertEquals(voiceFor(es, undefined, "fr")?.id, "fr", "fr before fr-be"); + assertEquals(voiceFor(es, "en-gb", "fr")?.id, "en-gb", "the chosen voice wins"); + assertEquals(voiceFor(es, "German", undefined)?.id, "de", "by name"); + assertEquals(voiceFor(es, undefined, "ja"), undefined); + const piper = [piperVoice("/m/fr_FR-siwis-low.onnx", undefined), piperVoice("/m/fr_FR-tom-medium.onnx", undefined)]; + assertEquals(voiceFor(piper, undefined, "fr")?.id, "/m/fr_FR-tom-medium.onnx", "better quality first"); +}); + +Deno.test("text", () => { + assertEquals( + cleanText( + "# Title\n\nRead **this** on https://www.example.com/a/b?c=d and [the docs](https://deno.com/x).\n\n\n\nEnd", + ), + "Title\n\nRead this on example.com and the docs.\n\nEnd", + ); + assertEquals(cleanText("```\ncode\n```"), ""); + assertEquals(duration("word ".repeat(160)), 60); + assertEquals(duration("word ".repeat(160), 2), 30); + assertEquals(clock(75), "1:15"); + assertEquals(speedOf(150), 1.5); + assertEquals(speedOf(undefined), 1); + assertEquals(speedOf(1000), 3); +}); + +Deno.test("speak scripts", () => { + const file = "/home/ada/.cache/backslash/tts/text.txt"; + const es = parseEspeakVoices(ESPEAK); + assertEquals( + speakScript({ engine: "espeak-ng", file, voice: es[4], speed: 1.2, cache: "/c" }), + `exec espeak-ng -v 'fr' -s 210 -f '${file}'`, + ); + assertEquals( + speakScript({ engine: "espeak-ng", file, language: "de", speed: 1, cache: "/c" }), + `exec espeak-ng -v 'de' -s 175 -f '${file}'`, + ); + assertEquals( + speakScript({ engine: "spd-say", file, language: "fr", speed: 0.5, cache: "/c" }), + `exec spd-say -w -e -r -50 -l 'fr' < '${file}'`, + ); + assertEquals(speakScript({ engine: "festival", file, speed: 1, cache: "/c" }), `exec festival --tts '${file}'`); + const piper = speakScript({ + engine: "piper", + file, + voice: piperVoice("/m/it's.onnx", JSON.stringify({ audio: { sample_rate: 16000 } })), + speed: 2, + cache: "/c", + }); + assertStringIncludes( + piper, + `piper --model '/m/it'\\''s.onnx' --length_scale 0.50 --output-raw < '${file}' | aplay -q -r 16000`, + ); + assertStringIncludes(piper, "paplay --raw --rate=16000"); + assertStringIncludes(piper, "pw-play '/c/speech.wav'"); + const args = systemdRunArgs("true", { XDG_RUNTIME_DIR: "/run/user/1000", PULSE_SERVER: undefined }); + assertEquals(args.slice(0, 3), ["--user", "--quiet", "--unit=backslash-text-to-speech"]); + assert(args.includes("--setenv=XDG_RUNTIME_DIR=/run/user/1000") && !args.some((a) => a.includes("PULSE"))); +}); + +Deno.test("install hints", () => { + assertEquals(osFamily("ID=fedora\n"), "fedora"); + assertStringIncludes(installMarkdown("arch"), "sudo pacman -S espeak-ng"); + assertStringIncludes(installMarkdown(undefined), "sudo apt install espeak-ng"); +}); diff --git a/extensions/text-to-speech/system.ts b/extensions/text-to-speech/system.ts new file mode 100644 index 0000000..4f3a4b0 --- /dev/null +++ b/extensions/text-to-speech/system.ts @@ -0,0 +1,215 @@ +// Installed engines, voices, and speaking in a background systemd unit. + +import { exec, storage } from "@backslash/api"; +import { + type Engine, + ENGINES, + type Family, + guessLanguage, + osFamily, + parseEspeakVoices, + parseSpdVoices, + piperVoice, + speakScript, + systemdRunArgs, + UNIT, + type Voice, + voiceFor, +} 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/tts`; +export const PIPER_DIR = `${HOME}/.local/share/piper`; +export const tilde = (p: string) => (HOME && p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p); +const expand = (p: string) => p.replace(/^~(?=\/|$)/, HOME); + +export interface Prefs { + engine?: string; + voice?: string; + speed?: number; + piperModels?: string; +} + +export async function family(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function installed(program: string, args: string[]): Promise<boolean> { + try { + await new Deno.Command(program, { + args, + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }).output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +const PROBE: Record<Engine, string[]> = { + piper: ["--help"], + "espeak-ng": ["--version"], + "spd-say": ["--version"], + festival: ["--version"], +}; + +/** Engines installed, Piper only when it has a voice model. */ +export async function engines(prefs: Prefs): Promise<{ available: Set<Engine>; piperWithoutModel: boolean }> { + const found = await Promise.all(ENGINES.map((e) => installed(e, PROBE[e]))); + const available = new Set(ENGINES.filter((_, i) => found[i])); + let piperWithoutModel = false; + if (available.has("piper") && !(await piperVoices(prefs)).length) { + available.delete("piper"); + piperWithoutModel = true; + } + return { available, piperWithoutModel }; +} + +/** Models in the Piper folder (or the one model the preference points at). */ +export async function piperVoices(prefs: Prefs): Promise<Voice[]> { + const where = expand(prefs.piperModels?.trim() || PIPER_DIR); + const paths: string[] = []; + if (where.endsWith(".onnx")) paths.push(where); + else { + const walk = async (dir: string, depth: number) => { + try { + for await (const e of Deno.readDir(dir)) { + const p = `${dir}/${e.name}`; + if (e.isFile && e.name.endsWith(".onnx")) paths.push(p); + else if (e.isDirectory && depth < 3) await walk(p, depth + 1); + } + } catch { + // Missing or unreadable. + } + }; + await walk(where, 0); + } + const voices = await Promise.all( + paths.map(async (p) => piperVoice(p, await Deno.readTextFile(`${p}.json`).catch(() => undefined))), + ); + return voices.sort((a, b) => a.language.localeCompare(b.language) || a.name.localeCompare(b.name)); +} + +export async function voices(engine: Engine, prefs: Prefs): Promise<Voice[]> { + try { + switch (engine) { + case "piper": + return await piperVoices(prefs); + case "espeak-ng": + return parseEspeakVoices(await exec("espeak-ng", ["--voices"])); + case "spd-say": + return parseSpdVoices(await exec("spd-say", ["-L"])); + case "festival": + return []; + } + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// The chosen voice (Voices command), per engine + +export const chosenVoices = async () => ((await storage.get("voices")) ?? {}) as Record<string, string>; + +export async function chooseVoice(v: Voice | { engine: Engine; id: undefined }): Promise<void> { + const all = await chosenVoices(); + if (v.id) all[v.engine] = v.id; + else delete all[v.engine]; + await storage.set("voices", all); +} + +// --------------------------------------------------------------------------- +// Speaking + +export async function speaking(): Promise<boolean> { + const state = await exec("systemctl", ["--user", "is-active", UNIT]).catch((e) => String(e)); + return /^(active|activating)$/m.test(state.trim()); +} + +export async function stop(): Promise<void> { + await exec("systemctl", ["--user", "stop", UNIT]).catch(() => {}); + await exec("systemctl", ["--user", "reset-failed", UNIT]).catch(() => {}); + // Speech Dispatcher keeps talking after spd-say is gone. + if ((await storage.get("engine")) === "spd-say") await exec("spd-say", ["-C"]).catch(() => {}); +} + +export interface Spoken { + engine: Engine; + voice?: Voice; + language?: string; +} + +/** Starts reading `text` aloud in a systemd user unit, stopping what was being read. */ +export async function speak(text: string, engine: Engine, prefs: Prefs, speed: number): Promise<Spoken> { + await stop(); + await Deno.mkdir(CACHE, { recursive: true }); + const file = `${CACHE}/text.txt`; + await Deno.writeTextFile(file, text); + const language = guessLanguage(text); + const list = engine === "festival" ? [] : await voices(engine, prefs); + const chosen = prefs.voice?.trim() || (await chosenVoices())[engine]; + const voice = voiceFor(list, chosen, language) ?? (engine === "piper" ? list[0] : undefined); + if (engine === "piper" && !voice) { + throw new Error(`No Piper voice in ${tilde(expand(prefs.piperModels?.trim() || PIPER_DIR))}`); + } + const script = speakScript({ engine, file, voice, language, speed, cache: CACHE }); + const sessionEnv = Object.fromEntries( + ["PULSE_SERVER", "PIPEWIRE_REMOTE", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].map((k) => [k, env(k)]), + ); + try { + await exec("systemd-run", systemdRunArgs(script, sessionEnv)); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to speak in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: speech runs in the background with systemd-run --user"); + } + throw e; + } + await storage.set("engine", engine); + await storage.set("last", { text: text.slice(0, 2000), at: Date.now(), engine, voice: voice?.name ?? null }); + return { engine, voice, language }; +} + +export interface Last { + text: string; + at: number; + engine: Engine; + voice: string | null; +} + +export const last = async () => (await storage.get("last")) as unknown as Last | undefined; + +/** The primary selection (the text selected with the mouse), Wayland or X11. */ +export async function selection(): Promise<string> { + for ( + const [program, args] of [["wl-paste", ["--primary", "--no-newline"]], ["xclip", ["-o", "-selection", "primary"]], [ + "xsel", + ["-o", "-p"], + ]] as const + ) { + try { + const out = await new Deno.Command(program, { args: [...args], stdin: "null", stdout: "piped", stderr: "null" }) + .output(); + if (out.success) { + const text = new TextDecoder().decode(out.stdout); + if (text.trim()) return text; + } + } catch { + // Not installed. + } + } + return ""; +} diff --git a/extensions/text-to-speech/ui.ts b/extensions/text-to-speech/ui.ts new file mode 100644 index 0000000..cf33f82 --- /dev/null +++ b/extensions/text-to-speech/ui.ts @@ -0,0 +1,143 @@ +// Speaking from any command, the empty state, and voice items. + +import { action, close, type Context, Icons, type Item, meta, refresh, updateCommandMetadata } from "@backslash/api"; +import { + cleanText, + clock, + duration, + type Engine, + ENGINE_NAMES, + installMarkdown, + parseEngine, + pickEngine, + preview, + speedOf, + type Voice, +} from "./lib.ts"; +import { engines, family, type Prefs, speak, stop } from "./system.ts"; + +export const prefsOf = (ctx: Context) => ctx.preferences as Prefs; + +export class NoEngine extends Error {} + +/** The engine to use: the preference, else the first installed one. */ +export async function engineFor( + prefs: Prefs, +): Promise<{ engine?: Engine; available: Set<Engine>; piperWithoutModel: boolean }> { + const found = await engines(prefs); + return { ...found, engine: pickEngine(parseEngine(prefs.engine), found.available) }; +} + +/** Speaks and describes it: "Speaking with eSpeak NG (French)…". Throws NoEngine with what to install. */ +export async function say(text: string, prefs: Prefs): Promise<string> { + const clean = cleanText(text); + if (!clean) throw new Error("Nothing to read: the text is empty"); + const { engine, piperWithoutModel } = await engineFor(prefs); + if (!engine) { + const wanted = parseEngine(prefs.engine); + throw new NoEngine( + wanted !== "auto" + ? `${ENGINE_NAMES[wanted]} is not installed${wanted === "piper" && piperWithoutModel ? " or has no voice" : ""}` + : "No speech engine installed: install espeak-ng", + ); + } + const spoken = await speak(clean, engine, prefs, speedOf(prefs.speed)); + updateCommandMetadata({ subtitle: `Speaking… ${preview(clean, 40)}` }).catch(() => {}); + const who = spoken.voice ? spoken.voice.name : spoken.language ? spoken.language.toUpperCase() : ENGINE_NAMES[engine]; + return `Speaking with ${ENGINE_NAMES[engine]} · ${who}`; +} + +/** An action that speaks the text and hides the launcher. */ +export const speakAction = (title: string, text: () => Promise<string> | string, prefs: Prefs, shortcut?: string) => + action(title, async () => close(await say(await text(), prefs)), { icon: Icons.speaker, shortcut }); + +export const stopAction = () => + action("Stop Speaking", async () => { + await stop(); + updateCommandMetadata({ subtitle: null }).catch(() => {}); + return refresh(); + }, { icon: Icons.stop, shortcut: "<Ctrl>." }); + +/** "Speak …" with an estimate of how long it takes. */ +export function textItem( + id: string, + title: string, + text: string, + prefs: Prefs, + section: string, + icon: string = Icons.text, +): Item { + const clean = cleanText(text); + const secs = duration(clean, speedOf(prefs.speed)); + return { + id, + title, + subtitle: preview(clean, 70), + icon: { source: icon, tint: "blue" }, + section, + accessories: [{ text: `~${clock(secs)}`, tooltip: "Estimated reading time" }], + detail: { + markdown: clean.length > 4000 ? `${clean.slice(0, 4000)}…` : clean, + metadata: [ + meta.text("Words", (clean.match(/[\p{L}\p{N}]+/gu) ?? []).length), + meta.text("Reading time", `about ${clock(secs)}`, { icon: Icons.clock }), + meta.text("Speed", `${Math.round(speedOf(prefs.speed) * 100)} %`, { icon: Icons.bolt }), + ], + }, + actions: [speakAction(title, () => text, prefs)], + }; +} + +export async function installItem(piperWithoutModel: boolean): Promise<Item> { + const fam = await family(); + return { + id: "install", + title: piperWithoutModel ? "Piper has no voice yet" : "No speech engine installed", + subtitle: piperWithoutModel + ? "Add a voice model to ~/.local/share/piper, or install espeak-ng" + : "Install espeak-ng to start", + icon: { source: Icons.alert, tint: "orange" }, + detail: { markdown: installMarkdown(fam) }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; +} + +export function voiceItem(v: Voice, active: boolean, onChoose: () => Promise<void>, prefs: Prefs): Item { + const sample = v.language.startsWith("fr") + ? "Bonjour ! Voici à quoi ressemble cette voix." + : v.language.startsWith("de") + ? "Hallo! So klingt diese Stimme." + : v.language.startsWith("es") + ? "¡Hola! Así suena esta voz." + : "Hello! This is what this voice sounds like."; + return { + id: `${v.engine}:${v.id}`, + title: v.name, + subtitle: v.language, + icon: { source: Icons.speaker, tint: active ? "green" : "gray" }, + section: ENGINE_NAMES[v.engine], + keywords: [v.language, v.engine, v.gender ?? "", v.quality ?? ""], + accessories: [ + ...(active ? [{ tag: "Default", color: "green" as const }] : []), + ...(v.quality ? [{ text: v.quality }] : []), + ...(v.gender ? [{ text: v.gender }] : []), + ], + detail: { + metadata: [ + meta.text("Engine", ENGINE_NAMES[v.engine], { icon: Icons.speaker }), + meta.text("Language", v.language, { icon: Icons.globe }), + ...(v.gender ? [meta.text("Gender", v.gender)] : []), + ...(v.quality ? [meta.text("Quality", v.quality)] : []), + ...(v.sampleRate ? [meta.text("Sample rate", `${v.sampleRate} Hz`)] : []), + meta.text(v.engine === "piper" ? "Model" : "Voice", v.id, { icon: Icons.file }), + ], + }, + actions: [ + action(active ? "Default Voice" : "Use This Voice", async () => { + await onChoose(); + return refresh(); + }, { icon: Icons.check }), + speakAction("Preview Voice", () => sample, { ...prefs, engine: v.engine, voice: v.id }, "<Ctrl>p"), + ], + }; +} diff --git a/extensions/ticktick/api.ts b/extensions/ticktick/api.ts new file mode 100644 index 0000000..73bc5b5 --- /dev/null +++ b/extensions/ticktick/api.ts @@ -0,0 +1,129 @@ +// Calls to the TickTick Open API, signed in with the user's own TickTick app. + +import { action, Icons, type Item, oauth, open, refresh } from "@backslash/api"; +import { + API, + AuthError, + DEFAULT_PORT, + DEVELOPER_URL, + INBOX, + type Project, + provider, + type Task, + tickError, +} from "./lib.ts"; + +export interface Client { + call<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T>; + signOut(): Promise<void>; +} + +/** Signs in through the browser the first time (the launcher handles the redirect). */ +export async function client(prefs: Record<string, unknown>): Promise<Client> { + const p = provider(prefs); + const token = await oauth.token(p); + return { + async call<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> { + let res: Response; + try { + res = await fetch(`${API}${path}`, { + method, + signal, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`TickTick is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) { + if (res.status === 401) await oauth.signOut(p).catch(() => {}); + throw tickError(res.status, text); + } + return (text ? JSON.parse(text) : undefined) as T; + }, + signOut: () => oauth.signOut(p), + }; +} + +/** Runs `fn` on every item, `limit` at a time. */ +async function pool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]); + } + })); + return out; +} + +/** Every list (the inbox first) and its open tasks. */ +export async function everything(api: Client, signal?: AbortSignal): Promise<{ projects: Project[]; tasks: Task[] }> { + const lists = (await api.call<Project[]>("GET", "/project", undefined, signal)).filter((p) => !p.closed); + type Data = { project?: Project; tasks?: Task[] }; + const data = await pool([INBOX, ...lists], 5, async (p): Promise<Data> => { + try { + return await api.call<Data>("GET", `/project/${p.id}/data`, undefined, signal); + } catch (e) { + // The inbox isn't served to every account; the other lists are. + if (p === INBOX && !(e instanceof AuthError) && (e as Error).name !== "AbortError") return { tasks: [] }; + throw e; + } + }); + const inboxId = data[0].tasks?.[0]?.projectId ?? data[0].project?.id; + const projects = [{ ...INBOX, id: inboxId ?? INBOX.id }, ...lists]; + const tasks = data.flatMap((d) => d.tasks ?? []).filter((t) => t.status === 0); + return { projects, tasks }; +} + +export function setupItems(error: AuthError, prefs: Record<string, unknown>): Item[] { + if (error.message === "TickTick signed you out") { + return [{ + id: "setup", + title: "TickTick signed you out", + subtitle: "Sign in again in the browser", + icon: { source: Icons.key, tint: "orange" }, + actions: [action("Sign In", () => refresh(), { icon: Icons.key })], + }]; + } + const port = Number(prefs.redirectPort) || DEFAULT_PORT; + return [{ + id: "setup", + title: error.message, + subtitle: "TickTick signs in with an app of your own", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect TickTick", + "", + "TickTick's Open API has no personal tokens: you sign in through an app you register (free):", + "", + "1. Open **developer.ticktick.com → Manage Apps** (Enter) and sign in with your TickTick account.", + "2. **+ App Name**: call it *Backslash*, then edit it and set the **OAuth redirect URL** to", + ` \`http://127.0.0.1:${port}/callback\``, + "3. Copy its **Client ID** and **Client Secret** to **Settings → Extensions → TickTick → Configure**.", + "4. Open **TickTick Tasks** again: your browser asks you to allow access, then the launcher comes back.", + "", + `Using another port? Change **Redirect port** too; it must match the redirect URL exactly.`, + "Dida365 (the Chinese service) is not supported.", + ].join("\n"), + }, + actions: [open(DEVELOPER_URL, "Open TickTick Developer", { icon: Icons.key })], + }]; +} + +export async function guarded(prefs: Record<string, unknown>, load: (api: Client) => Promise<Item[]>): Promise<Item[]> { + try { + return await load(await client(prefs)); + } catch (e) { + if (e instanceof AuthError) return setupItems(e, prefs); + throw e; + } +} diff --git a/extensions/ticktick/backslash.toml b/extensions/ticktick/backslash.toml new file mode 100644 index 0000000..7fd1e27 --- /dev/null +++ b/extensions/ticktick/backslash.toml @@ -0,0 +1,49 @@ +[extension] +id = "ticktick" +title = "TickTick" +description = "Today's and upcoming TickTick tasks by project; complete, reschedule and quick-add tasks in plain language" +version = "1.0.0" +author = "Backslash" +icon = "bs-square-check" +license = "MIT" + +# The Open API, and the sign-in (token endpoint) on ticktick.com. +[permissions] +net = ["api.ticktick.com", "ticktick.com"] + +[[commands]] +name = "tasks" +title = "TickTick Tasks" +description = "Overdue, today and upcoming, across your lists; Enter completes" +icon = "bs-square-check" +keywords = ["ticktick", "tasks", "todo", "today", "upcoming", "tâches", "aujourd'hui", "à faire"] + +[[commands]] +name = "add" +title = "Add TickTick Task" +description = "“Call Bob tomorrow 3pm ^Work !high”: date, list and priority are understood" +icon = "bs-plus" +mode = "no-view" +keywords = ["ticktick", "add", "new", "task", "quick add", "ajouter", "nouvelle tâche"] +[[commands.arguments]] +name = "task" +placeholder = "Call Bob tomorrow 3pm ^Work !high" +required = true + +[[preferences]] +name = "clientId" +title = "Client ID" +description = "Create an app on developer.ticktick.com → Manage Apps, with the OAuth redirect URL http://127.0.0.1:43118/callback, and paste its Client ID" + +[[preferences]] +name = "clientSecret" +title = "Client Secret" +description = "The Client Secret of the same app" +type = "secret" + +[[preferences]] +name = "redirectPort" +title = "Redirect port" +description = "The port of the redirect URL registered in your app (default 43118)" +type = "number" +default = 43118 diff --git a/extensions/ticktick/commands/add.ts b/extensions/ticktick/commands/add.ts new file mode 100644 index 0000000..f22094c --- /dev/null +++ b/extensions/ticktick/commands/add.ts @@ -0,0 +1,18 @@ +import { run } from "@backslash/api"; +import { client, everything } from "../api.ts"; +import { AuthError } from "../lib.ts"; +import { createTask } from "../views.ts"; + +export default run<{ task: string }>(async (ctx) => { + const text = String(ctx.args.task ?? "").trim(); + if (!text) throw new Error("Type the task after the command"); + try { + const api = await client(ctx.preferences); + const { projects } = await everything(api); + const { summary } = await createTask(api, projects, text); + return summary; + } catch (e) { + if (e instanceof AuthError) throw new Error(`${e.message}: open TickTick Tasks to connect`); + throw e; + } +}); diff --git a/extensions/ticktick/commands/tasks.ts b/extensions/ticktick/commands/tasks.ts new file mode 100644 index 0000000..5514508 --- /dev/null +++ b/extensions/ticktick/commands/tasks.ts @@ -0,0 +1,59 @@ +import { action, type Context, Icons, list, updateCommandMetadata } from "@backslash/api"; +import { client, everything, guarded } from "../api.ts"; +import { type Project, sortTasks, taskSection } from "../lib.ts"; +import { live, notifyChanged } from "../live.ts"; +import { taskForm, taskItem } from "../views.ts"; + +let summary = ""; +let lists: Project[] = []; + +export default (ctx: Context) => + list({ + placeholder: "Filter tasks by title, list, tag…", + filter: { + options: [ + { value: "today", title: "Today" }, + { value: "week", title: "Next 7 Days" }, + { value: "all", title: "All" }, + ], + }, + empty: { title: "Nothing to do", description: "Enjoy the rest of the day.", icon: Icons.squareCheck }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + actions: [ + action("New Task…", async () => taskForm(await client(ctx.preferences), lists), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + }), + action("Sign Out of TickTick", async () => { + await (await client(ctx.preferences)).signOut(); + notifyChanged(); + }, { icon: Icons.power, section: "Account" }), + ], + items: ({ signal, filter }) => + live(() => + guarded(ctx.preferences, async (api) => { + const { projects, tasks } = await everything(api, signal); + lists = projects; + const now = new Date(); + const sections = new Map(tasks.map((t) => [t.id, taskSection(t, now)])); + const overdue = tasks.filter((t) => sections.get(t.id) === "Overdue").length; + const today = tasks.filter((t) => sections.get(t.id) === "Today").length; + updateCommandMetadata({ + subtitle: + [today ? `${today} today` : "", overdue ? `${overdue} overdue` : ""].filter(Boolean).join(" · ") || + null, + }).catch(() => {}); + const keep = filter === "all" + ? () => true + : filter === "week" + ? (s: string) => s !== "Later" && s !== "No Due Date" + : (s: string) => s === "Overdue" || s === "Today"; + const shown = tasks.filter((t) => keep(sections.get(t.id)!)); + summary = [ + `${shown.length} task${shown.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + `${projects.length} lists`, + ].filter(Boolean).join(" · "); + return sortTasks(shown, now).map((t) => taskItem(api, t, projects, now)); + }), signal), + }); diff --git a/extensions/ticktick/lib.ts b/extensions/ticktick/lib.ts new file mode 100644 index 0000000..aa1eba8 --- /dev/null +++ b/extensions/ticktick/lib.ts @@ -0,0 +1,453 @@ +// TickTick Open API: the sign-in, response types and pure logic (quick add, dates, priorities). + +import { type Color, Icons, meta, type Metadata, type OAuthProvider } from "@backslash/api"; + +export const API = "https://api.ticktick.com/open/v1"; +export const DEVELOPER_URL = "https://developer.ticktick.com/manage"; +export const DEFAULT_PORT = 43118; + +/** The app isn't set up, or TickTick refused the token: the list shows how to connect. */ +export class AuthError extends Error {} + +/** The user's own TickTick app (TickTick has no personal tokens). */ +export function provider(prefs: Record<string, unknown>): OAuthProvider { + const clientId = String(prefs.clientId ?? "").trim(); + const clientSecret = String(prefs.clientSecret ?? "").trim(); + if (!clientId) throw new AuthError("Connect your TickTick account"); + if (!clientSecret) throw new AuthError("Add the Client Secret of your TickTick app"); + const raw = prefs.redirectPort; + const port = raw === undefined || raw === null || raw === "" ? DEFAULT_PORT : Number(raw); + return { + id: "ticktick", + title: "TickTick", + authorizeUrl: "https://ticktick.com/oauth/authorize", + tokenUrl: "https://ticktick.com/oauth/token", + clientId, + clientSecret, + clientAuth: "basic", + scopes: ["tasks:read", "tasks:write"], + redirectPort: port > 0 && port < 65536 ? Math.round(port) : DEFAULT_PORT, + }; +} + +export function tickError(status: number, text: string): Error { + if (status === 401) return new AuthError("TickTick signed you out"); + if (status === 403) return new Error("TickTick refused it: the app needs the tasks:read and tasks:write scopes"); + if (status === 404) return new Error("Not found on TickTick: it may have been deleted"); + if (status === 429) return new Error("TickTick is rate limiting: try again in a minute"); + let message = ""; + try { + const body = JSON.parse(text); + message = body.errorMessage ?? body.error_description ?? body.message ?? ""; + } catch { + message = text.slice(0, 160); + } + return new Error(`TickTick answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Project { + id: string; + name: string; + color?: string | null; + closed?: boolean | null; + kind?: string; +} + +export interface Task { + id: string; + projectId: string; + title: string; + content?: string; + desc?: string; + isAllDay?: boolean; + startDate?: string | null; + dueDate?: string | null; + timeZone?: string; + repeatFlag?: string | null; + priority: number; + status: number; + items?: { id: string; title: string; status: number }[]; + tags?: string[]; + sortOrder?: number; +} + +export const INBOX: Project = { id: "inbox", name: "Inbox" }; + +// --------------------------------------------------------------------------- +// Dates + +/** `2026-10-29T15:00:00.000+0000` (TickTick's format) → a Date. */ +export function tickDate(s: string | null | undefined): Date | undefined { + if (!s) return undefined; + const d = new Date(s.replace(/([+-]\d{2})(\d{2})$/, "$1:$2")); + return isNaN(d.getTime()) ? undefined : d; +} + +/** A Date in TickTick's format, UTC. */ +export function toTick(d: Date): string { + return d.toISOString().replace(/\.\d{3}Z$/, ".000+0000"); +} + +export function dueOf(task: Task): { date: Date; time: boolean } | undefined { + const date = tickDate(task.dueDate) ?? tickDate(task.startDate); + return date ? { date, time: !task.isAllDay } : undefined; +} + +/** The date fields of a task due at `when` (all day or at a time). */ +export function dateFields(when: When, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone) { + const at = when.time ? when.date : new Date(when.date.getFullYear(), when.date.getMonth(), when.date.getDate()); + return { startDate: toTick(at), dueDate: toTick(at), isAllDay: !when.time, timeZone }; +} + +export function taskSection(task: Task, now = new Date()): string { + const due = dueOf(task); + return dueBucket(due?.date, now, due?.time ?? false); +} + +export function sortTasks(tasks: Task[], now = new Date()): Task[] { + const rank = (t: Task) => BUCKETS.indexOf(taskSection(t, now)); + const when = (t: Task) => dueOf(t)?.date.getTime() ?? Infinity; + return [...tasks].sort((a, b) => + rank(a) - rank(b) || when(a) - when(b) || b.priority - a.priority || (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ); +} + +// --------------------------------------------------------------------------- +// Priorities and colors + +export const PRIORITIES = [ + { value: 5, name: "High", color: "red" as Color }, + { value: 3, name: "Medium", color: "yellow" as Color }, + { value: 1, name: "Low", color: "blue" as Color }, + { value: 0, name: "None", color: "gray" as Color }, +]; + +export const priorityOf = (n: number) => PRIORITIES.find((p) => p.value === n) ?? PRIORITIES[3]; + +/** The palette color closest to a `#rrggbb` color. */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +export const taskUrl = (t: Pick<Task, "id" | "projectId">) => + `https://ticktick.com/webapp/#p/${t.projectId}/tasks/${t.id}`; + +/** "Every week" from `RRULE:FREQ=WEEKLY;INTERVAL=1`. */ +export function repeatText(rule: string | null | undefined): string | undefined { + if (!rule) return undefined; + const freq = /FREQ=(\w+)/.exec(rule)?.[1]; + const interval = Number(/INTERVAL=(\d+)/.exec(rule)?.[1] ?? 1); + const unit = { DAILY: "day", WEEKLY: "week", MONTHLY: "month", YEARLY: "year" }[freq ?? ""]; + if (!unit) return "Repeats"; + return interval > 1 ? `Every ${interval} ${unit}s` : `Every ${unit}`; +} + +export function checklist(task: Task): { done: number; total: number } { + const items = task.items ?? []; + return { done: items.filter((i) => i.status !== 0).length, total: items.length }; +} + +export function taskMetadata(task: Task, project: Project | undefined, now = new Date()): Metadata[] { + const lines: Metadata[] = []; + const due = dueOf(task); + lines.push( + due + ? meta.text("Due", shortDue(due.date, now, due.time), { + icon: Icons.calendar, + color: taskSection(task, now) === "Overdue" ? "red" : undefined, + }) + : meta.text("Due", "No date", { icon: Icons.calendar }), + ); + const repeat = repeatText(task.repeatFlag); + if (repeat) lines.push(meta.text("Repeats", repeat, { icon: Icons.repeat })); + const p = priorityOf(task.priority); + lines.push(meta.text("Priority", p.name, { icon: Icons.flag, color: p.value ? p.color : undefined })); + if (project) lines.push(meta.text("List", project.name, { icon: Icons.dot, color: colorOf(project.color) })); + if (task.tags?.length) lines.push(meta.tags("Tags", task.tags)); + const list = checklist(task); + if (list.total) lines.push(meta.progress("Checklist", list.done / list.total, `${list.done} of ${list.total}`)); + return lines; +} + +/** The note, and the checklist as a markdown list. */ +export function taskMarkdown(task: Task): string | undefined { + const parts: string[] = []; + const note = (task.content || task.desc || "").trim(); + if (note) parts.push(note.length > 600 ? `${note.slice(0, 600).trimEnd()}…` : note); + const items = task.items ?? []; + if (items.length) { + parts.push(items.map((i) => `- ${i.status !== 0 ? "~~" : ""}${i.title}${i.status !== 0 ? "~~" : ""}`).join("\n")); + } + return parts.join("\n\n") || undefined; +} + +// --------------------------------------------------------------------------- +// Quick add + +export interface QuickAdd { + title: string; + when?: When; + priority: number; + /** The list typed after ^ or ~, as typed. */ + project?: string; +} + +const PRIORITY_WORDS: Record<string, number> = { high: 5, h: 5, medium: 3, med: 3, m: 3, low: 1, l: 1, none: 0 }; + +/** Words that are a date on their own, safe to take out of a title. */ +const SAFE_SINGLE = + /^(today|tonight|tomorrow|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d{1,2}(:\d{2})?(am|pm)|\d{1,2}:\d{2}|\d{1,2}h\d{2}|\d{4}-\d{2}-\d{2}|\+\d{1,3}[dw])$/i; + +const TIME_ONLY = /^\d{1,2}([:h.]\d{2})? ?(am|pm)?$/i; + +/** + * The longest, rightmost run of words that reads as a date, taken out of + * `words`. `want` limits it to a time alone or a day alone. + */ +function takeWhen(words: string[], now: Date, want?: "time" | "day"): { when: When; timeOnly: boolean } | undefined { + for (let len = Math.min(4, words.length); len >= 1; len--) { + for (let start = words.length - len; start >= 0; start--) { + const window = words.slice(start, start + len); + if (window.some((w) => /^[!#^~]/.test(w))) continue; + const text = window.join(" "); + if (len === 1 && !SAFE_SINGLE.test(text)) continue; + const timeOnly = TIME_ONLY.test(text); + if ((want === "time" && !timeOnly) || (want === "day" && timeOnly)) continue; + let when: When | undefined; + try { + when = parseWhen(text, now); + } catch { + continue; + } + if (!when || (want === "day" && when.time)) continue; + // "at 3pm", "on friday", "by monday", "due tomorrow". + const from = start > 0 && /^(at|on|by|due|@)$/i.test(words[start - 1]) ? start - 1 : start; + words.splice(from, start + len - from); + return { when, timeOnly }; + } + } + return undefined; +} + +/** + * "Call Bob tomorrow 3pm ^Work !high" → title "Call Bob", tomorrow at 15:00, + * list "Work", high priority. `#tags` stay in the title (the Open API takes no tags). + */ +export function parseQuickAdd(input: string, now = new Date()): QuickAdd { + let words = input.trim().split(/\s+/).filter(Boolean); + let priority = 0; + let project: string | undefined; + words = words.filter((w) => { + // !high, !med, !low, !none, or ! / !! / !!! for low, medium, high. + const bangs = /^!{1,3}$/.exec(w); + const word = /^!([a-z]+)$/i.exec(w); + const value = bangs ? [1, 3, 5][w.length - 1] : word ? PRIORITY_WORDS[word[1].toLowerCase()] : undefined; + if (value !== undefined) { + priority = value; + return false; + } + const list = /^[\^~](.+)$/.exec(w); + if (list) { + project = list[1].replace(/_/g, " "); + return false; + } + return true; + }); + const found = takeWhen(words, now); + let when = found?.when; + // A day and, elsewhere, a time: "tomorrow call Bob at 3pm". + if (found && (found.timeOnly || !found.when.time)) { + const other = takeWhen(words, now, found.timeOnly ? "day" : "time"); + if (other) { + const [day, time] = found.timeOnly ? [other.when.date, found.when.date] : [found.when.date, other.when.date]; + when = { + date: new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.getHours(), time.getMinutes()), + time: true, + }; + } + } + const title = words.join(" ").trim(); + return { title: title || input.trim(), when, priority, project }; +} + +/** The list a quick add names: exact name, then a prefix, ignoring case and emoji. */ +export function findProject(projects: Project[], name: string | undefined): Project | undefined { + if (!name) return undefined; + const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, "").trim(); + const n = norm(name); + return projects.find((p) => norm(p.name) === n) ?? projects.find((p) => norm(p.name).startsWith(n)); +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/ticktick/lib_test.ts b/extensions/ticktick/lib_test.ts new file mode 100644 index 0000000..0e0f542 --- /dev/null +++ b/extensions/ticktick/lib_test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertInstanceOf, assertThrows } from "@std/assert"; +import { + AuthError, + checklist, + colorOf, + dateFields, + dueBucket, + dueChoices, + dueOf, + findProject, + parseQuickAdd, + parseWhen, + priorityOf, + provider, + repeatText, + shortDue, + sortTasks, + type Task, + taskMarkdown, + taskSection, + tickDate, + tickError, + toTick, +} from "./lib.ts"; + +Deno.test("provider: the user's own app, basic auth, the registered port", () => { + assertThrows(() => provider({}), AuthError, "Connect"); + assertThrows(() => provider({ clientId: "abc" }), AuthError, "Client Secret"); + const p = provider({ clientId: " abc ", clientSecret: " s3cret ", redirectPort: 43118 }); + assertEquals(p.clientId, "abc"); + assertEquals(p.clientSecret, "s3cret"); + assertEquals(p.clientAuth, "basic"); + assertEquals(p.redirectPort, 43118); + assertEquals(p.scopes, ["tasks:read", "tasks:write"]); + assertEquals(new URL(p.tokenUrl).host, "ticktick.com"); + assertEquals(provider({ clientId: "a", clientSecret: "b", redirectPort: "" }).redirectPort, 43118); + assertEquals(provider({ clientId: "a", clientSecret: "b", redirectPort: 9000 }).redirectPort, 9000); +}); + +Deno.test("errors", () => { + assertInstanceOf(tickError(401, ""), AuthError); + assertEquals(tickError(500, '{"errorMessage":"boom"}').message, "TickTick answered 500: boom"); + assertEquals(tickError(400, "bad request").message, "TickTick answered 400: bad request"); +}); + +Deno.test("TickTick dates", () => { + assertEquals(tickDate("2026-10-29T15:00:00.000+0000")?.toISOString(), "2026-10-29T15:00:00.000Z"); + assertEquals(tickDate("2026-10-29T15:00:00+0100")?.toISOString(), "2026-10-29T14:00:00.000Z"); + assertEquals(tickDate(""), undefined); + assertEquals(tickDate("nope"), undefined); + assertEquals(toTick(new Date("2026-10-29T15:00:00Z")), "2026-10-29T15:00:00.000+0000"); + const allDay = dateFields({ date: new Date(2026, 9, 30, 12), time: false }, "Europe/Paris"); + assertEquals(allDay.isAllDay, true); + assertEquals(allDay.timeZone, "Europe/Paris"); + assertEquals(tickDate(allDay.dueDate)?.getTime(), new Date(2026, 9, 30).getTime()); + const atTime = dateFields({ date: new Date(2026, 9, 30, 15, 30), time: true }, "UTC"); + assertEquals(atTime.isAllDay, false); + assertEquals(tickDate(atTime.startDate)?.getTime(), new Date(2026, 9, 30, 15, 30).getTime()); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); +const task = (over: Partial<Task>): Task => ({ + id: "t", + projectId: "p", + title: "Task", + priority: 0, + status: 0, + ...over, +}); + +Deno.test("tasks: due, sections, order", () => { + const allDayToday = task({ id: "a", isAllDay: true, dueDate: toTick(new Date(2026, 9, 27)) }); + const lateAtTime = task({ id: "b", isAllDay: false, dueDate: toTick(new Date(2026, 9, 27, 9)) }); + const soon = task({ id: "c", isAllDay: false, dueDate: toTick(new Date(2026, 9, 28, 9)), priority: 5 }); + const none = task({ id: "d" }); + assertEquals(dueOf(allDayToday)?.time, false); + assertEquals(taskSection(allDayToday, NOW), "Today"); + assertEquals(taskSection(lateAtTime, NOW), "Overdue"); + assertEquals(taskSection(soon, NOW), "Tomorrow"); + assertEquals(taskSection(none, NOW), "No Due Date"); + assertEquals(sortTasks([none, soon, allDayToday, lateAtTime], NOW).map((t) => t.id), ["b", "a", "c", "d"]); + // A task with only a start date is due then. + assertEquals(taskSection(task({ isAllDay: true, startDate: toTick(new Date(2026, 9, 27)) }), NOW), "Today"); +}); + +Deno.test("priorities, colors, repeats, checklists", () => { + assertEquals(priorityOf(5).name, "High"); + assertEquals(priorityOf(3).color, "yellow"); + assertEquals(priorityOf(42).name, "None"); + assertEquals(colorOf("#F18181"), "red"); + assertEquals(colorOf("#4CA1FF"), "blue"); + assertEquals(repeatText("RRULE:FREQ=WEEKLY;INTERVAL=1"), "Every week"); + assertEquals(repeatText("RRULE:FREQ=DAILY;INTERVAL=2"), "Every 2 days"); + assertEquals(repeatText(null), undefined); + const t = task({ + content: "Bring the slides", + items: [{ id: "1", title: "Print", status: 1 }, { id: "2", title: "Staple", status: 0 }], + }); + assertEquals(checklist(t), { done: 1, total: 2 }); + assertEquals(taskMarkdown(t), "Bring the slides\n\n- ~~Print~~\n- Staple"); + assertEquals(taskMarkdown(task({})), undefined); +}); + +Deno.test("quick add: dates, times, lists and priority", () => { + const a = parseQuickAdd("Call Bob tomorrow 3pm ^Work !high", NOW); + assertEquals(a.title, "Call Bob"); + assertEquals(a.when, { date: new Date(2026, 9, 28, 15), time: true }); + assertEquals(a.project, "Work"); + assertEquals(a.priority, 5); + + const b = parseQuickAdd("Pay rent on friday", NOW); + assertEquals(b.title, "Pay rent"); + assertEquals(b.when, { date: new Date(2026, 9, 30), time: false }); + + const c = parseQuickAdd("tomorrow call mom at 18:30 !!", NOW); + assertEquals(c.title, "call mom"); + assertEquals(c.when, { date: new Date(2026, 9, 28, 18, 30), time: true }); + assertEquals(c.priority, 3); + + const d = parseQuickAdd("Dentist next week ~Personal_Stuff", NOW); + assertEquals(d.title, "Dentist"); + assertEquals(d.when?.date, new Date(2026, 10, 2)); + assertEquals(d.project, "Personal Stuff"); + + // Words that only look like dates stay in the title. + const e = parseQuickAdd("Call Tom about the sun cream for 1h", NOW); + assertEquals(e.title, "Call Tom about the sun cream for 1h"); + assertEquals(e.when, undefined); + assertEquals(e.priority, 0); + + const f = parseQuickAdd("Buy 3 apples #groceries 2026-11-03", NOW); + assertEquals(f.title, "Buy 3 apples #groceries"); + assertEquals(f.when, { date: new Date(2026, 10, 3), time: false }); + + assertEquals(parseQuickAdd("Read 3 nov issue !low", NOW).when?.date, new Date(2026, 10, 3)); + assertEquals(parseQuickAdd("today", NOW).title, "today"); +}); + +Deno.test("find the list a quick add names", () => { + const lists = [{ id: "1", name: "📥 Inbox" }, { id: "2", name: "Work" }, { id: "3", name: "Personal stuff" }]; + assertEquals(findProject(lists, "work")?.id, "2"); + assertEquals(findProject(lists, "personal")?.id, "3"); + assertEquals(findProject(lists, "inbox")?.id, "1"); + assertEquals(findProject(lists, "garden"), undefined); + assertEquals(findProject(lists, undefined), undefined); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/ticktick/live.ts b/extensions/ticktick/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/ticktick/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live<T>(load: () => Promise<T>, signal: AbortSignal): AsyncGenerator<T> { + while (!signal.aborted) { + yield await load(); + await new Promise<void>((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/ticktick/views.ts b/extensions/ticktick/views.ts new file mode 100644 index 0000000..26797c3 --- /dev/null +++ b/extensions/ticktick/views.ts @@ -0,0 +1,161 @@ +// Items, actions and the new task form. + +import { type Action, action, copy, field, form, Icons, type Item, open, pop, submenu, toast } from "@backslash/api"; +import type { Client } from "./api.ts"; +import { + checklist, + colorOf, + dateFields, + dueChoices, + dueOf, + findProject, + INBOX, + parseQuickAdd, + parseWhen, + PRIORITIES, + priorityOf, + type Project, + repeatText, + shortDue, + type Task, + taskMarkdown, + taskMetadata, + taskSection, + taskUrl, +} from "./lib.ts"; +import { notifyChanged } from "./live.ts"; + +async function edit(api: Client, task: Task, fields: Record<string, unknown>, message: string) { + await api.call("POST", `/task/${task.id}`, { id: task.id, projectId: task.projectId, ...fields }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function taskActions(api: Client, task: Task, projects: Project[]): Action[] { + return [ + action("Complete Task", async () => { + await api.call("POST", `/project/${task.projectId}/task/${task.id}/complete`); + notifyChanged(); + await toast(task.repeatFlag ? "Done, next one scheduled" : `Completed “${task.title}”`, { style: "success" }); + }, { icon: Icons.squareCheck }), + open(taskUrl(task), "Open in TickTick"), + submenu( + "Change Due Date", + dueChoices().filter((c) => c.date).map((c) => + action(c.title, () => { + const when = parseWhen(c.date!)!; + // Keep the time of day of a task due at a time. + const due = dueOf(task); + if (due?.time) when.date.setHours(due.date.getHours(), due.date.getMinutes()); + return edit(api, task, dateFields({ date: when.date, time: !!due?.time }), `Due ${c.title.toLowerCase()}`); + }, { icon: Icons.calendar }) + ), + { icon: Icons.calendar, shortcut: "<Ctrl>d", section: "Task" }, + ), + submenu( + "Set Priority", + PRIORITIES.map((p) => + action(p.name, () => edit(api, task, { priority: p.value }, `Priority ${p.name.toLowerCase()}`), { + icon: Icons.flag, + }) + ), + { icon: Icons.flag, shortcut: "<Ctrl>p", section: "Task" }, + ), + action("New Task…", () => taskForm(api, projects, { project: task.projectId }), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + section: "Task", + }), + copy(task.title, "Copy Title", { shortcut: "<Ctrl>period", section: "Copy" }), + copy(taskUrl(task), "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + action("Delete Task", async () => { + await api.call("DELETE", `/project/${task.projectId}/task/${task.id}`); + notifyChanged(); + await toast("Deleted", { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${task.title}”?`, + shortcut: "<Ctrl><Shift>Delete", + section: "Danger", + }), + ]; +} + +export function taskItem(api: Client, task: Task, projects: Project[], now = new Date()): Item { + const project = projects.find((p) => p.id === task.projectId); + const due = dueOf(task); + const prio = priorityOf(task.priority); + const list = checklist(task); + const section = taskSection(task, now); + return { + id: task.id, + title: task.title || "Untitled", + subtitle: (task.content || task.desc || "").split("\n")[0].replace(/[*_`#>]/g, "").trim().slice(0, 80) || undefined, + icon: { source: Icons.square, tint: prio.color }, + section, + keywords: [project?.name ?? "", ...(task.tags ?? [])].filter(Boolean), + accessories: [ + ...(list.total + ? [{ + icon: Icons.squareCheck, + text: `${list.done}/${list.total}`, + color: list.done === list.total ? "green" as const : undefined, + tooltip: "Checklist", + }] + : []), + ...(task.repeatFlag ? [{ icon: Icons.repeat, tooltip: repeatText(task.repeatFlag) }] : []), + ...(due + ? [{ + icon: Icons.calendar, + // In "Today", the time says more than "today". + text: section === "Today" && !due.time ? "today" : shortDue(due.date, now, due.time).replace(/^today /, ""), + color: section === "Overdue" ? "red" as const : undefined, + tooltip: "Due", + }] + : []), + ...(project ? [{ icon: { source: Icons.dot, tint: colorOf(project.color) }, text: project.name }] : []), + ], + detail: { markdown: taskMarkdown(task), metadata: taskMetadata(task, project, now) }, + actions: taskActions(api, task, projects), + }; +} + +export async function createTask(api: Client, projects: Project[], text: string, projectId?: string) { + const q = parseQuickAdd(text); + const named = findProject(projects, q.project); + if (q.project && !named) throw new Error(`No TickTick list “${q.project}”`); + const target = named?.id ?? projectId; + const task = await api.call<Task>("POST", "/task", { + title: q.title, + priority: q.priority, + // Without a list, TickTick files it in the inbox. + ...(target && target !== INBOX.id && !/^inbox/.test(target) ? { projectId: target } : {}), + ...(q.when ? dateFields(q.when) : {}), + }); + notifyChanged(); + const where = named?.name ?? projects.find((p) => p.id === (target ?? task.projectId))?.name ?? "Inbox"; + const when = q.when ? ` · ${shortDue(q.when.date, new Date(), q.when.time)}` : ""; + return { task, summary: `Added “${q.title}” to ${where}${when}` }; +} + +export function taskForm(api: Client, projects: Project[], options: { project?: string } = {}) { + return form({ + title: "New TickTick task", + submitTitle: "Add Task", + fields: [ + field.text("title", "Task", { + required: true, + placeholder: "Call Bob tomorrow 3pm !high: dates and priority are understood", + }), + field.dropdown("project", "List", projects.map((p) => ({ value: p.id, title: p.name })), { + value: options.project ?? projects[0]?.id, + }), + ], + async submit(values) { + const { summary } = await createTask(api, projects, String(values.title), String(values.project)); + await toast(summary, { style: "success" }); + return pop(); + }, + }); +} diff --git a/extensions/todo-txt/backslash.toml b/extensions/todo-txt/backslash.toml new file mode 100644 index 0000000..ca63ae0 --- /dev/null +++ b/extensions/todo-txt/backslash.toml @@ -0,0 +1,57 @@ +[extension] +id = "todo-txt" +title = "Todo.txt" +description = "Your todo.txt tasks by due date, with priorities, +projects, @contexts, recurrence and quick add" +version = "1.0.0" +author = "Backslash" +icon = "bs-square-check" +license = "MIT" + +# Extensions declare their files ahead of time, so todo.txt is read from the +# usual places only: ~/todo.txt or ~/Documents/todo.txt (with done.txt next +# to it), or the folders that sync apps and todo.sh use, ~/Dropbox/todo +# (Simpletask, Markor) and ~/.todo. Writing is needed to add, complete and +# edit tasks; the file is watched to reload when another app changes it. +[permissions] +read = ["~/todo.txt", "~/done.txt", "~/Documents/todo.txt", "~/Documents/done.txt", "~/Dropbox/todo", "~/.todo"] +write = ["~/todo.txt", "~/done.txt", "~/Documents/todo.txt", "~/Documents/done.txt", "~/Dropbox/todo", "~/.todo"] +env = ["HOME"] + +[[commands]] +name = "tasks" +title = "Todo.txt Tasks" +description = "Overdue, today's and upcoming tasks; complete, edit, prioritize" +icon = "bs-square-check" +keywords = ["todo", "todo.txt", "tasks", "gtd", "due", "today", "tâches", "à faire", "liste"] + +[[commands]] +name = "add" +title = "Add Todo.txt Task" +description = "“(A) Call Bob +Work @phone due:fri rec:1w”, saved at once" +icon = "bs-plus" +mode = "no-view" +keywords = ["todo", "add", "task", "new", "capture", "ajouter", "tâche", "nouvelle"] +[[commands.arguments]] +name = "task" +placeholder = "(A) Call Bob +Work @phone due:fri" +required = true + +[[preferences]] +name = "file" +title = "todo.txt file" +description = "~/todo.txt, ~/Documents/todo.txt, or a file in ~/Dropbox/todo or ~/.todo. Empty: the first of these that exists" +default = "" + +[[preferences]] +name = "archive" +title = "Move completed tasks to done.txt" +description = "Off: completed tasks stay in todo.txt, marked “x”" +type = "boolean" +default = true + +[[preferences]] +name = "createdDate" +title = "Add the creation date to new tasks" +description = "“2026-10-28 Call Bob”, as todo.sh -t does" +type = "boolean" +default = true diff --git a/extensions/todo-txt/commands/add.ts b/extensions/todo-txt/commands/add.ts new file mode 100644 index 0000000..196747f --- /dev/null +++ b/extensions/todo-txt/commands/add.ts @@ -0,0 +1,15 @@ +import { run } from "@backslash/api"; +import { normalizeNewTask, parseTask, relativeDay, tildify } from "../lib.ts"; +import { changeLine, HOME, locate, prefs, today } from "../todo.ts"; + +// "Add Todo.txt Task (A) Call Bob +Work @phone due:fri": appended at once. +export default run<{ task: string }>(async (ctx) => { + const input = String(ctx.args.task ?? ctx.query ?? "").trim(); + if (!input) throw new Error("Type the task after the command"); + const files = await locate(ctx); + const line = normalizeNewTask(input, today(), prefs(ctx).addCreated); + await changeLine(files, undefined, [line]); + const t = parseTask(line); + const due = t.due ? `, due ${relativeDay(t.due, today())}` : ""; + return `Added to ${tildify(files.todo, HOME)}${due}`; +}); diff --git a/extensions/todo-txt/commands/tasks.ts b/extensions/todo-txt/commands/tasks.ts new file mode 100644 index 0000000..6b38d9a --- /dev/null +++ b/extensions/todo-txt/commands/tasks.ts @@ -0,0 +1,63 @@ +import { action, type Context, Icons, list, open, toast, updateCommandMetadata } from "@backslash/api"; +import { dueSummary, matchesFilter, normalizeNewTask, sortTasks, tildify, topLabels } from "../lib.ts"; +import { changeLine, HOME, live, loadTasks, locate, prefs, taskForm, taskItem, today } from "../todo.ts"; + +let footer = ""; + +export default async (ctx: Context) => { + const files = await locate(ctx); + const { archive, addCreated } = prefs(ctx); + const open0 = (await loadTasks(files)).map((r) => r.task); + const { projects, contexts } = topLabels(open0); + const where = tildify(files.todo, HOME); + const newTask = action("New Task", () => taskForm(files, { text: ctx.query, addCreated }), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + }); + return list({ + placeholder: "Filter tasks by text, +project, @context or (A)…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "due", title: "Due This Week" }, + { value: "pri", title: "Prioritized" }, + ...projects.map((p) => ({ value: `+${p}`, title: `+${p}` })), + ...contexts.map((c) => ({ value: `@${c}`, title: `@${c}` })), + { value: "done", title: "Completed" }, + ], + }, + empty: files.exists ? { title: "No task here", description: "Press Ctrl+N to add one", icon: Icons.squareCheck } : { + title: `No todo.txt yet`, + description: `Press Ctrl+N to add your first task: it creates ${where}. ` + + "Set another file (e.g. ~/Dropbox/todo/todo.txt) in Settings → Extensions → Todo.txt.", + icon: Icons.squareCheck, + }, + status: () => footer, + actions: [ + newTask, + ...(ctx.query.trim() + ? [action(`Add “${ctx.query.trim()}”`, async () => { + await changeLine(files, undefined, [normalizeNewTask(ctx.query, today(), addCreated)]); + await toast("Task added", { style: "success" }); + }, { icon: Icons.plus })] + : []), + open(files.todo, "Open todo.txt", { icon: Icons.file, shortcut: "<Ctrl>o" }), + ], + items: ({ signal, filter }) => + live( + async () => { + const now = today(); + const rows = await loadTasks(files, filter === "done"); + const tasks = rows.filter((r) => !r.archived).map((r) => r.task); + const summary = dueSummary(tasks, now); + updateCommandMetadata({ subtitle: summary ?? null }).catch(() => {}); + const shown = sortTasks(rows.filter((r) => matchesFilter(r.task, filter, now)), now); + const openCount = tasks.filter((t) => !t.done).length; + footer = [`${openCount} open task${openCount === 1 ? "" : "s"}`, summary, where].filter(Boolean).join(" · "); + return shown.map((r) => taskItem(r, files, { archive, addCreated, now })); + }, + signal, + files.exists ? files.todo : undefined, + ), + }); +}; diff --git a/extensions/todo-txt/lib.ts b/extensions/todo-txt/lib.ts new file mode 100644 index 0000000..e7e59e5 --- /dev/null +++ b/extensions/todo-txt/lib.ts @@ -0,0 +1,518 @@ +// The todo.txt format (http://todotxt.org): parsing, writing, dates, +// recurrence and the views of a task list. Pure functions, no I/O. + +export interface Task { + /** The line as found in the file (without the line break). */ + raw: string; + done: boolean; + /** YYYY-MM-DD */ + completed?: string; + created?: string; + /** "A"…"Z"; for completed tasks, taken from `pri:A` when the priority was moved there. */ + priority?: string; + /** Everything after the completion mark, dates and priority. */ + text: string; + /** The words of `text` without +projects, @contexts and key:value pairs. */ + title: string; + projects: string[]; + contexts: string[]; + /** key:value pairs, keys lowercased (`due`, `t`, `rec`, `pri`, …). */ + tags: Record<string, string>; + due?: string; + threshold?: string; + rec?: string; +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const validDate = (s: string): boolean => DATE_RE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().startsWith(s); +export const isDate = (s: string | undefined): s is string => { + try { + return !!s && validDate(s); + } catch { + return false; + } +}; + +/** `due:2026-10-01`, `rec:+1w`, never URLs (`https://…`) nor times (`10:30`). */ +export function keyValue(word: string): [string, string] | undefined { + const m = /^([^\s:]+):([^\s:]+)$/.exec(word); + if (!m || m[2].startsWith("//") || /^\d+$/.test(m[1])) return undefined; + return [m[1].toLowerCase(), m[2]]; +} + +export function parseTask(raw: string): Task { + let rest = raw.trim(); + const take = (re: RegExp): string | undefined => { + const m = re.exec(rest); + if (!m) return undefined; + rest = rest.slice(m[0].length); + return m[1]; + }; + const DATE = /^(\d{4}-\d{2}-\d{2})(?:\s+|$)/; + const PRI = /^\(([A-Z])\)(?:\s+|$)/; + let done = false; + let completed: string | undefined; + let created: string | undefined; + let priority: string | undefined; + if (/^x(\s|$)/.test(rest)) { + done = true; + rest = rest.slice(1).trimStart(); + completed = take(DATE); + // Lenient: some apps keep the priority before or after the dates. + priority = take(PRI); + if (completed) created = take(DATE); + priority ??= take(PRI); + } else { + priority = take(PRI); + created = take(DATE); + } + const text = rest.trim(); + const projects: string[] = []; + const contexts: string[] = []; + const tags: Record<string, string> = {}; + const words: string[] = []; + for (const word of text.split(/\s+/).filter(Boolean)) { + if (/^\+\S+$/.test(word)) { + if (!projects.includes(word.slice(1))) projects.push(word.slice(1)); + } else if (/^@\S+$/.test(word)) { + if (!contexts.includes(word.slice(1))) contexts.push(word.slice(1)); + } else { + const kv = keyValue(word); + if (kv) tags[kv[0]] = kv[1]; + else words.push(word); + } + } + if (done && !priority && /^[A-Z]$/.test(tags.pri ?? "")) priority = tags.pri; + return { + raw, + done, + completed: isDate(completed) ? completed : undefined, + created: isDate(created) ? created : undefined, + priority, + text, + title: words.join(" ") || text, + projects, + contexts, + tags, + due: isDate(tags.due) ? tags.due : undefined, + threshold: isDate(tags.t) ? tags.t : undefined, + rec: tags.rec, + }; +} + +/** Lines of a file: blank lines dropped, `\r` removed. */ +export function parseFile(content: string): Task[] { + return content.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.trim()).map(parseTask); +} + +export interface TaskParts { + done?: boolean; + completed?: string; + created?: string; + priority?: string; + text: string; +} + +/** Builds a line; for completed tasks the priority becomes `pri:X`, as todo.sh does. */ +export function formatTask(p: TaskParts): string { + const parts: string[] = []; + let text = p.text.trim(); + if (p.done) { + parts.push("x"); + if (p.completed) parts.push(p.completed); + if (p.completed && p.created) parts.push(p.created); + if (p.priority && !/(^|\s)pri:[A-Z](\s|$)/.test(text)) text = `${text} pri:${p.priority}`.trim(); + } else { + if (p.priority) parts.push(`(${p.priority})`); + if (p.created) parts.push(p.created); + } + if (text) parts.push(text); + return parts.join(" "); +} + +/** Replaces (or removes, with `undefined`) one key:value in a description. */ +export function setTag(text: string, key: string, value: string | undefined): string { + const words = text.split(/\s+/).filter(Boolean); + const out: string[] = []; + let set = false; + for (const w of words) { + const kv = keyValue(w); + if (kv && kv[0] === key.toLowerCase()) { + if (value !== undefined && !set) out.push(`${key}:${value}`); + set = true; + } else out.push(w); + } + if (!set && value !== undefined) out.push(`${key}:${value}`); + return out.join(" "); +} + +// --- Dates (calendar days, no time zone involved) ---------------------------- + +/** Local date as YYYY-MM-DD. */ +export function isoDay(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +const toUtc = (iso: string) => { + const [y, m, d] = iso.split("-").map(Number); + return Date.UTC(y, m - 1, d); +}; +const fromUtc = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +/** Days from `a` to `b` (both YYYY-MM-DD). */ +export const daysBetween = (a: string, b: string) => Math.round((toUtc(b) - toUtc(a)) / 86_400_000); + +export function addDays(iso: string, n: number): string { + return fromUtc(toUtc(iso) + n * 86_400_000); +} + +/** Months added with the day kept, or the month's last day (Jan 31 + 1m = Feb 28). */ +export function addMonths(iso: string, n: number): string { + const [y, m, d] = iso.split("-").map(Number); + const first = new Date(Date.UTC(y, m - 1 + n, 1)); + const last = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate(); + first.setUTCDate(Math.min(d, last)); + return first.toISOString().slice(0, 10); +} + +/** 0 = Sunday. */ +export const weekday = (iso: string) => new Date(toUtc(iso)).getUTCDay(); + +export function addBusinessDays(iso: string, n: number): string { + let day = iso; + let left = n; + while (left > 0) { + day = addDays(day, 1); + if (weekday(day) !== 0 && weekday(day) !== 6) left--; + } + return day; +} + +export interface Recurrence { + /** `+`: from the due date (strict), else from the completion day. */ + strict: boolean; + amount: number; + unit: "d" | "b" | "w" | "m" | "y"; +} + +/** `rec:1w`, `rec:+2m`, `rec:3d`, `rec:5b` (business days), `rec:1y`; a bare `rec:w` means 1. */ +export function parseRecurrence(value: string | undefined): Recurrence | undefined { + const m = /^(\+)?(\d*)([dbwmy])$/i.exec(value ?? ""); + if (!m) return undefined; + const amount = m[2] ? Number(m[2]) : 1; + if (amount < 1) return undefined; + return { strict: !!m[1], amount, unit: m[3].toLowerCase() as Recurrence["unit"] }; +} + +export function addInterval(iso: string, r: Pick<Recurrence, "amount" | "unit">): string { + switch (r.unit) { + case "d": + return addDays(iso, r.amount); + case "b": + return addBusinessDays(iso, r.amount); + case "w": + return addDays(iso, 7 * r.amount); + case "m": + return addMonths(iso, r.amount); + case "y": + return addMonths(iso, 12 * r.amount); + } +} + +export function describeRecurrence(value: string | undefined): string | undefined { + const r = parseRecurrence(value); + if (!r) return value; + const names = { d: "day", b: "business day", w: "week", m: "month", y: "year" }; + const every = r.amount === 1 ? `Every ${names[r.unit]}` : `Every ${r.amount} ${names[r.unit]}s`; + return `${every}${r.strict ? ", from the due date" : ", from completion"}`; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; + +/** + * A date typed by a person: `2026-11-03`, `today`, `tomorrow`, `mon`…`sunday` + * (the next one, never today), `3d`, `+2w`, `1m`, `1y`, `next week`. + * Returns YYYY-MM-DD, or undefined if not understood. + */ +export function parseDateInput(input: string, today: string): string | undefined { + const s = input.trim().toLowerCase(); + if (!s) return undefined; + if (isDate(input.trim())) return s; + if (["today", "tod", "now"].includes(s)) return today; + if (["tomorrow", "tom", "tmr"].includes(s)) return addDays(today, 1); + if (s === "yesterday") return addDays(today, -1); + if (s === "next week") return addDays(today, 7); + if (s === "next month") return addMonths(today, 1); + const wd = s.length >= 3 ? WEEKDAYS.findIndex((w) => w.startsWith(s)) : -1; + if (wd >= 0) { + const diff = (wd - weekday(today) + 7) % 7 || 7; + return addDays(today, diff); + } + const rel = /^\+?(\d+)\s*([dbwmy])$/.exec(s); + if (rel) return addInterval(today, { amount: Number(rel[1]), unit: rel[2] as Recurrence["unit"] }); + return undefined; +} + +/** + * What `add` writes: relative `due:`/`t:` values made absolute + * (`due:tomorrow` → `due:2026-10-29`) and, with `addCreated`, today's date + * after the priority when the task has none. + */ +export function normalizeNewTask(input: string, today: string, addCreated: boolean): string { + const t = parseTask(input.trim()); + let text = t.text; + for (const key of ["due", "t"]) { + const v = t.tags[key]; + if (v && !isDate(v)) { + const d = parseDateInput(v, today); + if (d) text = setTag(text, key, d); + } + } + if (t.done) return formatTask({ ...t, text, completed: t.completed ?? today }); + return formatTask({ priority: t.priority, created: t.created ?? (addCreated ? today : undefined), text }); +} + +// --- Completing, with recurrence --------------------------------------------- + +export interface Completion { + /** The completed line. */ + done: string; + /** The next occurrence of a `rec:` task, to add to the list. */ + next?: string; +} + +export function completeTask(task: Task, today: string): Completion { + const done = formatTask({ + done: true, + completed: today, + created: task.created, + priority: task.priority, + text: task.text, + }); + const r = parseRecurrence(task.rec); + if (!r) return { done }; + let text = task.text; + if (task.due) { + const due = addInterval(r.strict ? task.due : today, r); + text = setTag(text, "due", due); + // The threshold keeps its distance to the due date. + if (task.threshold) text = setTag(text, "t", addDays(due, -daysBetween(task.threshold, task.due))); + } else if (task.threshold) { + text = setTag(text, "t", addInterval(r.strict ? task.threshold : today, r)); + } else { + text = setTag(text, "due", addInterval(today, r)); + } + const next = formatTask({ priority: task.priority, created: task.created ? today : undefined, text }); + return { done, next }; +} + +/** Back to an open task: the completion mark and date go, `pri:X` becomes the priority again. */ +export function uncompleteTask(task: Task): string { + const pri = task.tags.pri && /^[A-Z]$/.test(task.tags.pri) ? task.tags.pri : task.priority; + return formatTask({ priority: pri, created: task.created, text: setTag(task.text, "pri", undefined) }); +} + +export function setPriority(task: Task, priority: string | undefined): string { + if (task.done) { + return formatTask({ ...task, priority: undefined, text: setTag(task.text, "pri", priority) }); + } + return formatTask({ priority, created: task.created, text: task.text }); +} + +export function setDue(task: Task, due: string | undefined): string { + return formatTask({ ...task, text: setTag(task.text, "due", due) }); +} + +// --- Editing ----------------------------------------------------------------- + +export interface EditValues { + description: string; + priority: string; + due: string; + threshold: string; + rec: string; +} + +/** The form's fields for a task: the dates and recurrence apart from the description. */ +export function editValues(task: Task): EditValues { + let description = task.text; + for (const key of ["due", "t", "rec"]) description = setTag(description, key, undefined); + if (task.done) description = setTag(description, "pri", undefined); + return { + description, + priority: task.priority ?? "", + due: task.tags.due ?? "", + threshold: task.tags.t ?? "", + rec: task.tags.rec ?? "", + }; +} + +/** Builds the line from the form; throws a readable error on a bad date or recurrence. */ +export function applyEdit(task: Task | undefined, v: EditValues, today: string, addCreated: boolean): string { + const description = v.description.trim().replace(/\s+/g, " "); + if (!description) throw new Error("The task needs a description"); + const date = (label: string, s: string) => { + if (!s.trim()) return undefined; + const d = parseDateInput(s, today); + if (!d) throw new Error(`${label}: “${s}” is not a date (2026-11-03, tomorrow, fri, 3d…)`); + return d; + }; + const rec = v.rec.trim(); + if (rec && !parseRecurrence(rec)) throw new Error(`Recurrence: “${rec}” is not valid (1d, 2w, +1m, 1y, 5b)`); + const priority = v.priority.trim().toUpperCase(); + if (priority && !/^[A-Z]$/.test(priority)) throw new Error("Priority: a letter from A to Z"); + // A description typed with its own due:/t:/rec: wins over empty fields. + const typed = parseTask(description); + let text = description; + const due = date("Due", v.due) ?? (typed.tags.due ? date("Due", typed.tags.due) : undefined); + const t = date("Threshold", v.threshold) ?? (typed.tags.t ? date("Threshold", typed.tags.t) : undefined); + text = setTag(text, "due", due); + text = setTag(text, "t", t); + if (rec) text = setTag(text, "rec", rec); + if (!task) return formatTask({ priority: priority || undefined, created: addCreated ? today : undefined, text }); + return formatTask({ + done: task.done, + completed: task.completed, + created: task.created, + priority: priority || undefined, + text, + }); +} + +// --- Views ------------------------------------------------------------------- + +export type Section = "Overdue" | "Today" | "Upcoming" | "No Date" | "Completed"; +export const SECTIONS: Section[] = ["Overdue", "Today", "Upcoming", "No Date", "Completed"]; + +export function sectionOf(task: Task, today: string): Section { + if (task.done) return "Completed"; + if (task.due) { + const d = daysBetween(today, task.due); + return d < 0 ? "Overdue" : d === 0 ? "Today" : "Upcoming"; + } + // Not before a future threshold date: it is planned, so "Upcoming". + if (task.threshold && daysBetween(today, task.threshold) > 0) return "Upcoming"; + return "No Date"; +} + +const priRank = (t: Task) => t.priority ? t.priority.charCodeAt(0) - 65 : 26; + +/** By section, then due date, then priority, then the order in the file. */ +export function sortTasks<T extends { task: Task; index: number }>(rows: T[], today: string): T[] { + return [...rows].sort((a, b) => { + const sa = SECTIONS.indexOf(sectionOf(a.task, today)); + const sb = SECTIONS.indexOf(sectionOf(b.task, today)); + if (sa !== sb) return sa - sb; + if (a.task.done && b.task.done) { + return (b.task.completed ?? "").localeCompare(a.task.completed ?? "") || b.index - a.index; + } + const da = a.task.due ?? a.task.threshold ?? "9999"; + const db = b.task.due ?? b.task.threshold ?? "9999"; + return da.localeCompare(db) || priRank(a.task) - priRank(b.task) || a.index - b.index; + }); +} + +/** `today`, `tomorrow`, `yesterday`, `in 3 days`, `2 days ago`, then `Nov 14`. */ +export function relativeDay(iso: string, today: string): string { + const d = daysBetween(today, iso); + if (d === 0) return "today"; + if (d === 1) return "tomorrow"; + if (d === -1) return "yesterday"; + if (d > 1 && d < 7) return `in ${d} days`; + if (d < -1 && d > -14) return `${-d} days ago`; + const date = new Date(toUtc(iso)); + const sameYear = iso.slice(0, 4) === today.slice(0, 4); + return date.toLocaleDateString("en", { + month: "short", + day: "numeric", + year: sameYear ? undefined : "numeric", + timeZone: "UTC", + }); +} + +export function longDate(iso: string): string { + return new Date(toUtc(iso)).toLocaleDateString("en", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +} + +export type Tint = "red" | "orange" | "yellow" | "blue" | "green" | "gray" | "purple" | "teal"; + +export function priorityColor(p: string | undefined): Tint { + if (!p) return "gray"; + return ({ A: "red", B: "orange", C: "yellow" } as Record<string, Tint>)[p] ?? "blue"; +} + +/** Filter chips: `all`, `due`, `pri`, `done`, `+project`, `@context`. */ +export function matchesFilter(task: Task, filter: string | undefined, today: string): boolean { + if (!filter || filter === "all") return !task.done; + if (filter === "done") return task.done; + if (task.done) return false; + if (filter === "due") return !!task.due && daysBetween(today, task.due) <= 7; + if (filter === "pri") return !!task.priority; + if (filter.startsWith("+")) return task.projects.some((p) => p.toLowerCase() === filter.slice(1).toLowerCase()); + if (filter.startsWith("@")) return task.contexts.some((c) => c.toLowerCase() === filter.slice(1).toLowerCase()); + return true; +} + +/** The most used projects and contexts of the open tasks, for the filter chips. */ +export function topLabels(tasks: Task[], max = 6): { projects: string[]; contexts: string[] } { + const count = (pick: (t: Task) => string[]) => { + const n = new Map<string, number>(); + for (const t of tasks) if (!t.done) { for (const x of pick(t)) n.set(x, (n.get(x) ?? 0) + 1); } + return [...n].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, max).map(([k]) => k); + }; + return { projects: count((t) => t.projects), contexts: count((t) => t.contexts).slice(0, Math.ceil(max / 2)) }; +} + +/** "3 overdue · 2 due today", or undefined when nothing is due. */ +export function dueSummary(tasks: Task[], today: string): string | undefined { + const open = tasks.filter((t) => !t.done); + const overdue = open.filter((t) => sectionOf(t, today) === "Overdue").length; + const due = open.filter((t) => sectionOf(t, today) === "Today").length; + const parts = [overdue ? `${overdue} overdue` : "", due ? `${due} due today` : ""].filter(Boolean); + return parts.length ? parts.join(" · ") : undefined; +} + +// --- Files ------------------------------------------------------------------- + +/** + * Replaces the first line equal to `old` by `replacement` (removed when + * empty), or appends `replacement` when `old` is undefined. Keeps the file's + * line endings and its final line break. Returns undefined when `old` is not + * there any more (the file changed meanwhile). + */ +export function replaceLine( + content: string, + old: string | undefined, + replacement: string[], +): string | undefined { + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const lines = content === "" ? [] : content.split(/\r?\n/); + const trailing = lines.length > 0 && lines[lines.length - 1] === ""; + if (trailing) lines.pop(); + if (old === undefined) { + lines.push(...replacement); + } else { + const i = lines.findIndex((l) => l.trimEnd() === old.trimEnd()); + if (i < 0) return undefined; + lines.splice(i, 1, ...replacement); + } + return lines.length ? lines.join(eol) + eol : ""; +} + +/** `~/x` → `/home/me/x`. */ +export function expandHome(path: string, home: string): string { + return path === "~" ? home : path.startsWith("~/") ? `${home}${path.slice(1)}` : path; +} + +export function tildify(path: string, home: string): string { + return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export const dirname = (path: string) => path.replace(/\/[^/]*$/, "") || "/"; diff --git a/extensions/todo-txt/lib_test.ts b/extensions/todo-txt/lib_test.ts new file mode 100644 index 0000000..1ec91ac --- /dev/null +++ b/extensions/todo-txt/lib_test.ts @@ -0,0 +1,347 @@ +import { assert, assertEquals, assertThrows } from "@std/assert"; +import { + addBusinessDays, + addMonths, + applyEdit, + completeTask, + daysBetween, + describeRecurrence, + dueSummary, + editValues, + formatTask, + keyValue, + matchesFilter, + normalizeNewTask, + parseDateInput, + parseFile, + parseRecurrence, + parseTask, + relativeDay, + replaceLine, + sectionOf, + setDue, + setPriority, + setTag, + sortTasks, + topLabels, + uncompleteTask, +} from "./lib.ts"; + +const TODAY = "2026-10-28"; // a Wednesday + +Deno.test("parses a full open task", () => { + const t = parseTask("(A) 2026-10-01 Call Mom +Family @phone due:2026-10-30 t:2026-10-29 rec:+1w id:42"); + assertEquals(t.done, false); + assertEquals(t.priority, "A"); + assertEquals(t.created, "2026-10-01"); + assertEquals(t.title, "Call Mom"); + assertEquals(t.projects, ["Family"]); + assertEquals(t.contexts, ["phone"]); + assertEquals(t.due, "2026-10-30"); + assertEquals(t.threshold, "2026-10-29"); + assertEquals(t.rec, "+1w"); + assertEquals(t.tags.id, "42"); + assertEquals(t.text, "Call Mom +Family @phone due:2026-10-30 t:2026-10-29 rec:+1w id:42"); +}); + +Deno.test("parses completed tasks, with or without dates and pri:", () => { + const a = parseTask("x 2026-10-27 2026-10-01 Pay rent +Home pri:B"); + assertEquals([a.done, a.completed, a.created, a.priority, a.title], [ + true, + "2026-10-27", + "2026-10-01", + "B", + "Pay rent", + ]); + const b = parseTask("x Buy milk"); + assertEquals([b.done, b.completed, b.created, b.title], [true, undefined, undefined, "Buy milk"]); + const c = parseTask("x 2026-10-27 (A) Lenient app"); + assertEquals([c.completed, c.priority, c.title], ["2026-10-27", "A", "Lenient app"]); +}); + +Deno.test("what is not a priority, a date, a project or a tag", () => { + // Priority must be first, uppercase, followed by a space. + assertEquals(parseTask("Really (A) not first").priority, undefined); + assertEquals(parseTask("(a) lowercase").priority, undefined); + assertEquals(parseTask("(A)->arrow").priority, undefined); + // "x" must be followed by a space and be lowercase. + assertEquals(parseTask("xylophone lessons").done, false); + assertEquals(parseTask("X 2026-10-01 shout").done, false); + // Mid-text dates are text; e-mail addresses are not contexts; URLs and times are not tags. + const t = parseTask("Email bob@example.com about https://x.org/a?b=c at 10:30 on 2026-11-01 C++"); + assertEquals(t.contexts, []); + assertEquals(t.projects, []); + assertEquals(t.tags, {}); + assertEquals(t.created, undefined); + assertEquals(keyValue("due:2026-01-01"), ["due", "2026-01-01"]); + assertEquals(keyValue("DUE:x"), ["due", "x"]); + assertEquals(keyValue("a:b:c"), undefined); + assertEquals(keyValue("10:30"), undefined); + // An invalid date is kept as a tag but not as the due date. + const bad = parseTask("Thing due:2026-13-45"); + assertEquals([bad.due, bad.tags.due], [undefined, "2026-13-45"]); +}); + +Deno.test("a task made only of tags keeps them as its title", () => { + assertEquals(parseTask("+Garden @home").title, "+Garden @home"); + assertEquals(parseTask("(B) 2026-01-01").title, ""); +}); + +Deno.test("parseFile skips blank lines and Windows line breaks", () => { + const tasks = parseFile("(A) One\r\n\r\n \nTwo +p\r\n"); + assertEquals(tasks.map((t) => t.title), ["One", "Two"]); + assertEquals(tasks[0].raw, "(A) One"); +}); + +Deno.test("formatTask round-trips and moves the priority of done tasks to pri:", () => { + const lines = [ + "(A) 2026-10-01 Call Mom +Family @phone due:2026-10-30", + "x 2026-10-27 2026-10-01 Pay rent pri:B", + "Plain task", + ]; + for (const l of lines) assertEquals(formatTask(parseTask(l)), l); + assertEquals( + formatTask({ done: true, completed: "2026-10-28", created: "2026-10-01", priority: "A", text: "Do it" }), + "x 2026-10-28 2026-10-01 Do it pri:A", + ); + // A creation date needs a completion date on a done task. + assertEquals(formatTask({ done: true, created: "2026-10-01", text: "Do it" }), "x Do it"); +}); + +Deno.test("setTag replaces, adds and removes", () => { + assertEquals(setTag("Pay due:2026-01-01 +Home", "due", "2026-02-01"), "Pay due:2026-02-01 +Home"); + assertEquals(setTag("Pay +Home", "due", "2026-02-01"), "Pay +Home due:2026-02-01"); + assertEquals(setTag("Pay due:x due:y", "due", undefined), "Pay"); + assertEquals(setTag("See https://a.b/c", "https", undefined), "See https://a.b/c"); +}); + +Deno.test("calendar arithmetic", () => { + assertEquals(daysBetween("2026-03-28", "2026-03-30"), 2); // across a DST change in Europe + assertEquals(daysBetween("2024-02-28", "2024-03-01"), 2); // leap year + assertEquals(addMonths("2026-01-31", 1), "2026-02-28"); + assertEquals(addMonths("2024-01-31", 1), "2024-02-29"); + assertEquals(addMonths("2026-11-15", 3), "2027-02-15"); + assertEquals(addMonths("2024-02-29", 12), "2025-02-28"); + assertEquals(addBusinessDays("2026-10-30", 1), "2026-11-02"); // Friday → Monday + assertEquals(addBusinessDays("2026-10-28", 3), "2026-11-02"); +}); + +Deno.test("recurrence values", () => { + assertEquals(parseRecurrence("1w"), { strict: false, amount: 1, unit: "w" }); + assertEquals(parseRecurrence("+2m"), { strict: true, amount: 2, unit: "m" }); + assertEquals(parseRecurrence("d"), { strict: false, amount: 1, unit: "d" }); + assertEquals(parseRecurrence("5B"), { strict: false, amount: 5, unit: "b" }); + assertEquals(parseRecurrence("0d"), undefined); + assertEquals(parseRecurrence("weekly"), undefined); + assertEquals(describeRecurrence("+2w"), "Every 2 weeks, from the due date"); + assertEquals(describeRecurrence("1d"), "Every day, from completion"); +}); + +Deno.test("completing a plain task", () => { + const t = parseTask("(A) 2026-10-01 Call Mom +Family"); + assertEquals(completeTask(t, TODAY), { done: "x 2026-10-28 2026-10-01 Call Mom +Family pri:A" }); +}); + +Deno.test("completing a recurring task: from completion or strictly from the due date", () => { + const loose = parseTask("(B) 2026-10-01 Water plants due:2026-10-20 rec:1w"); + assertEquals(completeTask(loose, TODAY), { + done: "x 2026-10-28 2026-10-01 Water plants due:2026-10-20 rec:1w pri:B", + next: "(B) 2026-10-28 Water plants due:2026-11-04 rec:1w", + }); + const strict = parseTask("Pay rent due:2026-10-01 rec:+1m"); + assertEquals(completeTask(strict, TODAY).next, "Pay rent due:2026-11-01 rec:+1m"); + // Month ends are kept sensible. + assertEquals( + completeTask(parseTask("Report due:2026-01-31 rec:+1m"), "2026-01-31").next, + "Report due:2026-02-28 rec:+1m", + ); + // Yearly, across a leap day. + assertEquals( + completeTask(parseTask("Birthday due:2024-02-29 rec:+1y"), TODAY).next, + "Birthday due:2025-02-28 rec:+1y", + ); +}); + +Deno.test("recurrence keeps the threshold's distance and handles tasks without dates", () => { + const t = parseTask("Invoice due:2026-10-31 t:2026-10-25 rec:+1m"); + assertEquals(completeTask(t, TODAY).next, "Invoice due:2026-11-30 t:2026-11-24 rec:+1m"); + assertEquals(completeTask(parseTask("Stretch t:2026-10-28 rec:2d"), TODAY).next, "Stretch t:2026-10-30 rec:2d"); + assertEquals(completeTask(parseTask("Backup rec:1w"), TODAY).next, "Backup rec:1w due:2026-11-04"); + assertEquals(completeTask(parseTask("Standup due:2026-10-30 rec:+1b"), TODAY).next, "Standup due:2026-11-02 rec:+1b"); + // An invalid rec: is just completed. + assertEquals(completeTask(parseTask("Oops rec:often"), TODAY).next, undefined); +}); + +Deno.test("reopening a task restores its priority", () => { + assertEquals(uncompleteTask(parseTask("x 2026-10-27 2026-10-01 Pay rent pri:B")), "(B) 2026-10-01 Pay rent"); + assertEquals(uncompleteTask(parseTask("x Buy milk")), "Buy milk"); +}); + +Deno.test("priority and due date changes", () => { + const t = parseTask("(C) 2026-10-01 Read +Books"); + assertEquals(setPriority(t, "A"), "(A) 2026-10-01 Read +Books"); + assertEquals(setPriority(t, undefined), "2026-10-01 Read +Books"); + assertEquals(setPriority(parseTask("x 2026-10-27 Done thing pri:C"), "A"), "x 2026-10-27 Done thing pri:A"); + assertEquals(setDue(t, "2026-11-01"), "(C) 2026-10-01 Read +Books due:2026-11-01"); + assertEquals(setDue(parseTask("Read due:2026-11-01"), undefined), "Read"); +}); + +Deno.test("dates typed by a person", () => { + assertEquals(parseDateInput("2026-11-03", TODAY), "2026-11-03"); + assertEquals(parseDateInput("today", TODAY), TODAY); + assertEquals(parseDateInput("Tomorrow", TODAY), "2026-10-29"); + assertEquals(parseDateInput("fri", TODAY), "2026-10-30"); + assertEquals(parseDateInput("wednesday", TODAY), "2026-11-04"); // never today + assertEquals(parseDateInput("mon", TODAY), "2026-11-02"); + assertEquals(parseDateInput("3d", TODAY), "2026-10-31"); + assertEquals(parseDateInput("+2w", TODAY), "2026-11-11"); + assertEquals(parseDateInput("1m", TODAY), "2026-11-28"); + assertEquals(parseDateInput("next week", TODAY), "2026-11-04"); + assertEquals(parseDateInput("someday", TODAY), undefined); + assertEquals(parseDateInput("mo", TODAY), undefined); + assertEquals(parseDateInput("2026-02-30", TODAY), undefined); +}); + +Deno.test("new tasks get today's date and absolute due dates", () => { + assertEquals( + normalizeNewTask("(A) Call Bob +Work due:fri", TODAY, true), + "(A) 2026-10-28 Call Bob +Work due:2026-10-30", + ); + assertEquals(normalizeNewTask("Call Bob t:tomorrow", TODAY, false), "Call Bob t:2026-10-29"); + assertEquals(normalizeNewTask("2026-01-01 Old idea due:soon", TODAY, true), "2026-01-01 Old idea due:soon"); + assertEquals(normalizeNewTask("x Already done", TODAY, true), "x 2026-10-28 Already done"); +}); + +Deno.test("the edit form splits dates out and puts them back", () => { + const t = parseTask("(B) 2026-10-01 Call Mom +Family due:2026-10-30 rec:1w note:x"); + const v = editValues(t); + assertEquals(v, { + description: "Call Mom +Family note:x", + priority: "B", + due: "2026-10-30", + threshold: "", + rec: "1w", + }); + assertEquals(applyEdit(t, v, TODAY, true), "(B) 2026-10-01 Call Mom +Family note:x due:2026-10-30 rec:1w"); + assertEquals( + applyEdit(t, { ...v, priority: "", due: "tomorrow", rec: "" }, TODAY, true), + "2026-10-01 Call Mom +Family note:x due:2026-10-29", + ); + assertEquals( + applyEdit( + undefined, + { description: "New one due:fri", priority: "a", due: "", threshold: "", rec: "" }, + TODAY, + true, + ), + "(A) 2026-10-28 New one due:2026-10-30", + ); + assertThrows(() => applyEdit(t, { ...v, due: "whenever" }, TODAY, true), Error, "not a date"); + assertThrows(() => applyEdit(t, { ...v, rec: "often" }, TODAY, true), Error, "Recurrence"); + assertThrows(() => applyEdit(t, { ...v, description: " " }, TODAY, true), Error, "description"); + // Editing a done task keeps it done. + const done = parseTask("x 2026-10-27 2026-10-01 Pay rent pri:B"); + assertEquals(applyEdit(done, editValues(done), TODAY, true), "x 2026-10-27 2026-10-01 Pay rent pri:B"); +}); + +Deno.test("sections and sorting", () => { + const lines = [ + "Someday", + "(C) Later due:2026-11-20", + "x 2026-10-20 Old", + "(A) Late due:2026-10-01", + "(B) Now due:2026-10-28", + "(A) Now first due:2026-10-28", + "Hidden for now t:2026-11-01", + "(A) No date but urgent", + ]; + const rows = parseFile(lines.join("\n")).map((task, index) => ({ task, index })); + assertEquals(rows.map((r) => sectionOf(r.task, TODAY)), [ + "No Date", + "Upcoming", + "Completed", + "Overdue", + "Today", + "Today", + "Upcoming", + "No Date", + ]); + assertEquals(sortTasks(rows, TODAY).map((r) => r.task.title), [ + "Late", + "Now first", + "Now", + "Hidden for now", + "Later", + "No date but urgent", + "Someday", + "Old", + ]); + assertEquals(dueSummary(rows.map((r) => r.task), TODAY), "1 overdue · 2 due today"); + assertEquals(dueSummary([parseTask("Nothing due")], TODAY), undefined); +}); + +Deno.test("filters and the labels offered as chips", () => { + const tasks = parseFile([ + "(A) Call +Work @phone due:2026-10-30", + "Mail +Work @computer", + "Garden +Home due:2026-12-01", + "x Done +Work", + ].join("\n")); + const pick = (f: string) => tasks.filter((t) => matchesFilter(t, f, TODAY)).map((t) => t.title); + assertEquals(pick("all"), ["Call", "Mail", "Garden"]); + assertEquals(pick("due"), ["Call"]); + assertEquals(pick("pri"), ["Call"]); + assertEquals(pick("+work"), ["Call", "Mail"]); + assertEquals(pick("@phone"), ["Call"]); + assertEquals(pick("done"), ["Done"]); + assertEquals(topLabels(tasks), { projects: ["Work", "Home"], contexts: ["computer", "phone"] }); +}); + +Deno.test("relative days", () => { + assertEquals(relativeDay(TODAY, TODAY), "today"); + assertEquals(relativeDay("2026-10-29", TODAY), "tomorrow"); + assertEquals(relativeDay("2026-10-27", TODAY), "yesterday"); + assertEquals(relativeDay("2026-11-01", TODAY), "in 4 days"); + assertEquals(relativeDay("2026-10-20", TODAY), "8 days ago"); + assertEquals(relativeDay("2026-12-25", TODAY), "Dec 25"); + assertEquals(relativeDay("2027-01-02", TODAY), "Jan 2, 2027"); +}); + +Deno.test("replaceLine keeps line endings and detects concurrent edits", () => { + assertEquals(replaceLine("a\nb\nc\n", "b", ["B"]), "a\nB\nc\n"); + assertEquals(replaceLine("a\r\nb\r\n", "b", []), "a\r\n"); + assertEquals(replaceLine("a\nb", "b", ["x", "y"]), "a\nx\ny\n"); // a final line break is added + assertEquals(replaceLine("", undefined, ["first"]), "first\n"); + assertEquals(replaceLine("a\n", undefined, ["b"]), "a\nb\n"); + assertEquals(replaceLine("a\n", "a", []), ""); + assertEquals(replaceLine("a\n", "gone", ["x"]), undefined); + assert(replaceLine("a \n", "a", ["b"]) === "b\n"); +}); + +Deno.test("completing on disk archives to done.txt and adds the next occurrence", async () => { + const { complete, loadTasks, locate, changeLine } = await import("./todo.ts"); + const dir = await Deno.makeTempDir(); + try { + await Deno.writeTextFile(`${dir}/todo.txt`, "(B) 2026-10-01 Water plants due:2026-10-28 rec:+1w\nBuy milk\n"); + const files = await locate({ preferences: { file: `${dir}/todo.txt` } }); + assertEquals(files.done, `${dir}/done.txt`); + const [water] = (await loadTasks(files)).map((r) => r.task); + const message = await complete(files, water, true); + assert(message.startsWith("Done; next one")); + const todo = await Deno.readTextFile(`${dir}/todo.txt`); + assert(todo.startsWith("(B) ")); + assert(todo.includes("Water plants due:2026-11-04 rec:+1w")); + assert(todo.endsWith("Buy milk\n")); + assert( + (await Deno.readTextFile(`${dir}/done.txt`)).match(/^x \d{4}-\d{2}-\d{2} 2026-10-01 Water plants .* pri:B\n$/), + ); + // A line changed elsewhere meanwhile is not overwritten. + let error = ""; + await changeLine(files, "Not there", ["x"]).catch((e) => (error = e.message)); + assert(error.includes("changed")); + const withDone = await loadTasks(files, true); + assertEquals(withDone.filter((r) => r.archived).length, 1); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/extensions/todo-txt/todo.ts b/extensions/todo-txt/todo.ts new file mode 100644 index 0000000..e52b144 --- /dev/null +++ b/extensions/todo-txt/todo.ts @@ -0,0 +1,382 @@ +// The todo.txt file on disk, and its tasks as launcher items. + +import { + type Action, + action, + close, + type Context, + copy, + field, + form, + type FormSpec, + Icons, + type Item, + meta, + type Metadata, + open, + pop, + submenu, + toast, +} from "@backslash/api"; +import { + addDays, + applyEdit, + completeTask, + describeRecurrence, + dirname, + editValues, + expandHome, + isoDay, + longDate, + parseFile, + priorityColor, + relativeDay, + replaceLine, + sectionOf, + setDue, + setPriority, + type Task, + tildify, + uncompleteTask, + weekday, +} from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; + +/** Where todo.txt is looked for, in order; the same places as `[permissions]`. */ +export const CANDIDATES = ["~/todo.txt", "~/Documents/todo.txt", "~/Dropbox/todo/todo.txt", "~/.todo/todo.txt"]; +export const ALLOWED = "~/todo.txt, ~/Documents/todo.txt, or a file in ~/Dropbox/todo or ~/.todo"; + +export interface Files { + todo: string; + done: string; + exists: boolean; +} + +const exists = (path: string) => Deno.stat(path).then((s) => s.isFile, () => false); + +export async function locate(ctx: Pick<Context, "preferences">): Promise<Files> { + const pref = String(ctx.preferences.file ?? "").trim(); + if (pref) { + const todo = expandHome(pref, HOME); + return { todo, done: `${dirname(todo)}/done.txt`, exists: await exists(todo) }; + } + for (const c of CANDIDATES) { + const todo = expandHome(c, HOME); + if (await exists(todo)) return { todo, done: `${dirname(todo)}/done.txt`, exists: true }; + } + const todo = expandHome(CANDIDATES[0], HOME); + return { todo, done: `${dirname(todo)}/done.txt`, exists: false }; +} + +function explain(e: unknown, path: string): never { + if (e instanceof Deno.errors.NotCapable || e instanceof Deno.errors.PermissionDenied) { + throw new Error( + `Todo.txt may only use ${ALLOWED}; ${tildify(path, HOME)} is elsewhere. ` + + "Change the file in Settings → Extensions → Todo.txt.", + ); + } + throw e; +} + +export async function readText(path: string): Promise<string> { + try { + return await Deno.readTextFile(path); + } catch (e) { + if (e instanceof Deno.errors.NotFound) return ""; + explain(e, path); + } +} + +async function writeText(path: string, text: string) { + try { + await Deno.mkdir(dirname(path), { recursive: true }).catch(() => {}); + await Deno.writeTextFile(path, text); + } catch (e) { + explain(e, path); + } +} + +export interface Row { + task: Task; + index: number; + /** Lines of done.txt are shown, not changed. */ + archived?: boolean; +} + +export async function loadTasks(files: Files, withDone = false): Promise<Row[]> { + const rows = parseFile(await readText(files.todo)).map((task, index) => ({ task, index })); + if (!withDone) return rows; + const archived = parseFile(await readText(files.done)).slice(-200).map((task, index) => ({ + task, + index: -1000 + index, + archived: true, + })); + return [...rows, ...archived]; +} + +/** Changes one line of todo.txt; fails when it was edited elsewhere meanwhile. */ +export async function changeLine(files: Files, old: string | undefined, lines: string[]) { + const content = await readText(files.todo); + const next = replaceLine(content, old, lines); + if (next === undefined) throw new Error("This task changed in todo.txt meanwhile; the list was reloaded"); + await writeText(files.todo, next); + notifyChanged(); +} + +export async function appendDone(files: Files, line: string) { + const content = await readText(files.done); + await writeText(files.done, replaceLine(content, undefined, [line])!); +} + +export const today = () => isoDay(new Date()); + +/** Completes a task: archived to done.txt (or marked `x` in place), the next occurrence added for `rec:`. */ +export async function complete(files: Files, task: Task, archive: boolean): Promise<string> { + const { done, next } = completeTask(task, today()); + if (archive) { + await appendDone(files, done); + await changeLine(files, task.raw, next ? [next] : []); + } else { + await changeLine(files, task.raw, next ? [done, next] : [done]); + } + return next ? `Done; next one ${relativeDay(nextDue(next) ?? today(), today())}` : "Done"; +} + +const nextDue = (line: string) => parseFile(line)[0]?.due ?? parseFile(line)[0]?.threshold; + +// --- Live reload ------------------------------------------------------------- + +const changes = new EventTarget(); +export const notifyChanged = () => changes.dispatchEvent(new Event("change")); + +/** Yields `load()`, again after each change made here or to the file elsewhere, until `signal` aborts. */ +export async function* live<T>(load: () => Promise<T>, signal: AbortSignal, watch?: string): AsyncGenerator<T> { + let watcher: Deno.FsWatcher | undefined; + let fsChanged = () => {}; + if (watch) { + try { + watcher = Deno.watchFs(watch); + (async () => { + for await (const ev of watcher!) if (ev.kind !== "access") fsChanged(); + })().catch(() => {}); + } catch { /* no inotify, or the file doesn't exist yet */ } + } + try { + while (!signal.aborted) { + yield await load(); + await new Promise<void>((resolve) => { + let timer: ReturnType<typeof setTimeout> | undefined; + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + fsChanged = () => {}; + clearTimeout(timer); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + // Editors write in several steps: wait for them to settle. + fsChanged = () => { + clearTimeout(timer); + timer = setTimeout(done, 150); + }; + }); + } + } finally { + watcher?.close(); + } +} + +// --- Items ------------------------------------------------------------------- + +const PRIORITIES = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + +export function taskForm( + files: Files, + o: { task?: Task; text?: string; addCreated: boolean; standalone?: boolean }, +): FormSpec { + const v = o.task ? editValues(o.task) : { description: o.text ?? "", priority: "", due: "", threshold: "", rec: "" }; + return form({ + title: o.task ? "Edit Task" : "New Task", + submitTitle: o.task ? "Save Task" : "Add Task", + fields: [ + field.text("description", "Task", { + required: true, + placeholder: "Call the plumber +House @phone", + value: v.description, + }), + field.dropdown("priority", "Priority", [ + { value: "", title: "None" }, + ...PRIORITIES.map((p) => ({ value: p, title: `(${p})` })), + ], { value: v.priority }), + field.text("due", "Due", { placeholder: "2026-11-03, tomorrow, fri, 3d", value: v.due }), + field.text("threshold", "Not before (t:)", { + placeholder: "Hidden from today until this date", + value: v.threshold, + }), + field.text("rec", "Repeat (rec:)", { + placeholder: "1w, +1m (from the due date), 5b (business days)", + value: v.rec, + }), + ], + async submit(values) { + const line = applyEdit( + o.task, + { + description: String(values.description ?? ""), + priority: String(values.priority ?? ""), + due: String(values.due ?? ""), + threshold: String(values.threshold ?? ""), + rec: String(values.rec ?? ""), + }, + today(), + o.addCreated, + ); + await changeLine(files, o.task?.raw, [line]); + await toast(o.task ? "Task saved" : "Task added", { style: "success" }); + return o.standalone ? close("Task added") : pop(); + }, + }); +} + +function taskMetadata(row: Row, files: Files, now: string): Metadata[] { + const t = row.task; + const lines: Metadata[] = []; + if (t.priority) { + lines.push(meta.text("Priority", `(${t.priority})`, { icon: Icons.flag, color: priorityColor(t.priority) })); + } + if (t.due) { + const section = sectionOf(t, now); + lines.push(meta.text("Due", `${longDate(t.due)} · ${relativeDay(t.due, now)}`, { + icon: Icons.calendar, + color: t.done ? undefined : section === "Overdue" ? "red" : section === "Today" ? "orange" : undefined, + })); + } + if (t.threshold) lines.push(meta.text("Not before", longDate(t.threshold), { icon: Icons.hourglass })); + if (t.rec) lines.push(meta.text("Repeats", describeRecurrence(t.rec) ?? t.rec, { icon: Icons.repeat })); + if (t.projects.length) { + lines.push(meta.tags("Projects", t.projects.map((p) => ({ text: `+${p}`, color: "purple" as const })))); + } + if (t.contexts.length) { + lines.push(meta.tags("Contexts", t.contexts.map((c) => ({ text: `@${c}`, color: "teal" as const })))); + } + const other = Object.entries(t.tags).filter(([k]) => !["due", "t", "rec", "pri"].includes(k)); + for (const [k, v] of other.slice(0, 6)) lines.push(meta.text(k, v, { icon: Icons.tag })); + lines.push(meta.separator()); + if (t.created) lines.push(meta.text("Created", `${longDate(t.created)} · ${relativeDay(t.created, now)}`)); + if (t.completed) lines.push(meta.text("Completed", longDate(t.completed), { icon: Icons.done, color: "green" })); + const file = row.archived ? files.done : files.todo; + lines.push(meta.text(row.archived ? "In" : "Line", row.archived ? tildify(file, HOME) : `${row.index + 1}`)); + return lines; +} + +function dueAccessory(t: Task, now: string) { + const day = t.due ?? (t.threshold && t.threshold > now ? t.threshold : undefined); + if (!day || t.done) return []; + const section = sectionOf(t, now); + return [{ + icon: t.due ? Icons.calendar : Icons.hourglass, + text: relativeDay(day, now), + color: section === "Overdue" ? "red" as const : section === "Today" ? "orange" as const : undefined, + tooltip: `${t.due ? "Due" : "Not before"} ${longDate(day)}`, + }]; +} + +const nextMonday = (now: string) => addDays(now, ((8 - weekday(now)) % 7) || 7); + +export function taskItem(row: Row, files: Files, o: { archive: boolean; addCreated: boolean; now: string }): Item { + const t = row.task; + const now = o.now; + const section = sectionOf(t, now); + const edit = (line: string, message: string) => async () => { + await changeLine(files, t.raw, [line]); + await toast(message, { style: "success" }); + }; + const actions: Action[] = []; + if (!row.archived) { + actions.push( + t.done + ? action("Mark as Not Done", edit(uncompleteTask(t), "Reopened"), { icon: Icons.todo }) + : action("Complete Task", async () => { + await toast(await complete(files, t, o.archive), { style: "success" }); + }, { icon: Icons.done }), + action("Edit Task", () => taskForm(files, { task: t, addCreated: o.addCreated }), { + icon: Icons.text, + shortcut: "<Ctrl>e", + }), + submenu("Set Priority", [ + ...PRIORITIES.slice(0, 5).map((p) => + action(`(${p})`, edit(setPriority(t, p), `Priority (${p})`), { + icon: { A: Icons.urgent, B: Icons.high, C: Icons.medium, D: Icons.low }[p] ?? Icons.low, + }) + ), + action("No Priority", edit(setPriority(t, undefined), "Priority removed"), { icon: Icons.noPriority }), + ], { icon: Icons.flag, shortcut: "<Ctrl><Shift>p" }), + submenu("Set Due Date", [ + action("Today", edit(setDue(t, now), "Due today"), { icon: Icons.calendar }), + action("Tomorrow", edit(setDue(t, addDays(now, 1)), "Due tomorrow"), { icon: Icons.calendar }), + action("Next Monday", edit(setDue(t, nextMonday(now)), "Due next Monday"), { icon: Icons.calendar }), + action("In a Week", edit(setDue(t, addDays(now, 7)), "Due in a week"), { icon: Icons.calendar }), + ...(t.due + ? [action("Remove Due Date", edit(setDue(t, undefined), "Due date removed"), { icon: Icons.cross })] + : []), + ], { icon: Icons.calendar, shortcut: "<Ctrl><Shift>d" }), + ); + } + actions.push( + copy(t.title, "Copy Task", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(t.raw, "Copy todo.txt Line", { section: "Copy" }), + action("New Task", () => taskForm(files, { addCreated: o.addCreated }), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + section: "File", + }), + open(row.archived ? files.done : files.todo, `Open ${row.archived ? "done.txt" : "todo.txt"}`, { + icon: Icons.file, + shortcut: "<Ctrl>o", + section: "File", + }), + ); + if (!row.archived) { + actions.push(action("Delete Task", async () => { + await changeLine(files, t.raw, []); + await toast("Task deleted", { style: "success" }); + }, { + icon: Icons.trash, + destructive: true, + confirm: `Delete “${t.title}”? It is removed from todo.txt.`, + section: "Danger", + shortcut: "<Ctrl>Delete", + })); + } + return { + id: row.archived ? `done:${row.index}:${t.raw}` : t.raw, + title: t.title, + icon: t.done ? { source: Icons.done, tint: "green" } : { source: Icons.todo, tint: priorityColor(t.priority) }, + section: row.archived ? "Archived (done.txt)" : section, + keywords: [ + ...t.projects.map((p) => `+${p}`), + ...t.contexts.map((c) => `@${c}`), + ...(t.priority ? [`(${t.priority})`] : []), + ], + accessories: [ + ...(t.priority && !t.done ? [{ tag: `(${t.priority})`, color: priorityColor(t.priority) }] : []), + ...t.projects.slice(0, 2).map((p) => ({ tag: `+${p}`, color: "purple" as const })), + ...t.contexts.slice(0, 2).map((c) => ({ tag: `@${c}`, color: "teal" as const })), + ...(t.rec ? [{ icon: Icons.repeat, tooltip: describeRecurrence(t.rec) }] : []), + ...dueAccessory(t, now), + ...(t.done && t.completed ? [{ text: relativeDay(t.completed, now), tooltip: "Completed" }] : []), + ], + detail: { + markdown: `### ${t.title.replace(/[#*_`[\]]/g, "\\$&")}\n\n\`${t.raw.replace(/`/g, "'")}\``, + metadata: taskMetadata(row, files, now), + }, + actions, + }; +} + +export const prefs = (ctx: Pick<Context, "preferences">) => ({ + archive: ctx.preferences.archive !== false, + addCreated: ctx.preferences.createdDate !== false, +}); diff --git a/extensions/trash/backslash.toml b/extensions/trash/backslash.toml new file mode 100644 index 0000000..1d84525 --- /dev/null +++ b/extensions/trash/backslash.toml @@ -0,0 +1,38 @@ +[extension] +id = "trash" +title = "Trash" +description = "See what is in the trash, preview it, restore items or delete them for good" +version = "1.0.0" +author = "Backslash" +icon = "bs-trash" +license = "MIT" + +# The trash is read from its .trashinfo files (original path, deletion date). +# Restoring moves an item back with mv (mkdir recreates a missing folder), so +# the extension itself never writes outside the trash. du sizes folders, gio +# empties the trash on every drive. +[permissions] +run = ["gio", "du", "mv", "mkdir"] +# The home folder is read to check whether the original location still exists. +read = ["~"] +write = ["~/.local/share/Trash"] +env = ["HOME", "XDG_DATA_HOME"] + +[[commands]] +name = "trash" +title = "Trash" +description = "Items in the trash by deletion day: preview, restore, delete for good" +icon = "bs-trash" +keywords = [ + "trash", + "corbeille", + "bin", + "recycle", + "poubelle", + "deleted", + "supprimé", + "restore", + "restaurer", + "empty trash", + "vider la corbeille", +] diff --git a/extensions/trash/commands/trash.ts b/extensions/trash/commands/trash.ts new file mode 100644 index 0000000..99ad0a9 --- /dev/null +++ b/extensions/trash/commands/trash.ts @@ -0,0 +1,295 @@ +import { + type Action, + action, + close, + copy, + exec, + Icons, + type Item, + list, + meta, + open, + openExternal, + refresh, + toast, + updateCommandMetadata, +} from "@backslash/api"; +import { + basename, + daySection, + dirname, + formatBytes, + type Kind, + kindOf, + looksLikeText, + parseDirectorySizes, + parseTrashInfo, + previewable, + restoredName, + summary, + textual, + tilde, + when, +} from "../lib.ts"; + +const HOME = (() => { + try { + return Deno.env.get("HOME") ?? ""; + } catch { + return ""; + } +})(); +const DATA = (() => { + try { + return Deno.env.get("XDG_DATA_HOME") || `${HOME}/.local/share`; + } catch { + return `${HOME}/.local/share`; + } +})(); +const TRASH = `${DATA}/Trash`; +const t = (p: string) => tilde(p, HOME); + +interface Trashed { + /** The name in Trash/files. */ + name: string; + file: string; + original: string; + deleted?: Date; + isDir: boolean; + size: number; + kind: Kind; + /** Entries inside, for folders. */ + count?: number; +} + +const ICONS: Record<Kind, { icon: string; tint: "blue" | "purple" | "pink" | "orange" | "teal" | "gray" | "green" }> = { + folder: { icon: Icons.folder, tint: "blue" }, + image: { icon: Icons.image, tint: "purple" }, + video: { icon: Icons.film, tint: "pink" }, + audio: { icon: Icons.music, tint: "pink" }, + document: { icon: Icons.file, tint: "orange" }, + archive: { icon: Icons.archive, tint: "teal" }, + code: { icon: Icons.code, tint: "green" }, + text: { icon: Icons.text, tint: "gray" }, + file: { icon: Icons.file, tint: "gray" }, +}; + +async function exists(p: string) { + try { + await Deno.lstat(p); + return true; + } catch { + return false; + } +} + +async function folderSize(path: string): Promise<number> { + const out = await exec("du", ["-s", "-b", path], { signal: AbortSignal.timeout(10000) }).catch(() => ""); + return Number(out.split("\t")[0]) || 0; +} + +async function load(): Promise<Trashed[]> { + const sizes = parseDirectorySizes(await Deno.readTextFile(`${TRASH}/directorysizes`).catch(() => "")); + const out: Trashed[] = []; + try { + for await (const e of Deno.readDir(`${TRASH}/info`)) { + if (!e.name.endsWith(".trashinfo")) continue; + const name = e.name.slice(0, -".trashinfo".length); + const info = parseTrashInfo(await Deno.readTextFile(`${TRASH}/info/${e.name}`).catch(() => "")); + const file = `${TRASH}/files/${name}`; + const st = await Deno.lstat(file).catch(() => undefined); + if (!info || !st) continue; + const isDir = st.isDirectory; + out.push({ + name, + file, + original: info.path.startsWith("/") ? info.path : `${HOME}/${info.path}`, + deleted: info.deleted ?? st.mtime ?? undefined, + isDir, + size: isDir ? sizes.get(name) ?? -1 : st.size, + kind: kindOf(name, isDir), + }); + } + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + await Promise.all( + out.filter((i) => i.isDir).map(async (i) => { + if (i.size < 0) i.size = await folderSize(i.file); + let n = 0; + for await (const _ of Deno.readDir(i.file)) if (++n > 999) break; + i.count = n; + }), + ); + return out.sort((a, b) => (b.deleted?.getTime() ?? 0) - (a.deleted?.getTime() ?? 0)); +} + +async function preview(i: Trashed): Promise<string | undefined> { + if (i.isDir) { + const names: string[] = []; + for await (const e of Deno.readDir(i.file)) { + names.push(`${e.isDirectory ? "▸" : "·"} ${e.name}${e.isDirectory ? "/" : ""}`); + if (names.length >= 12) break; + } + return names.length ? "```\n" + names.join("\n") + "\n```" : "_Empty folder_"; + } + if (!textual(i.kind) && i.kind !== "file") return undefined; + if (i.size > 5_000_000) return undefined; + const f = await Deno.open(i.file).catch(() => undefined); + if (!f) return undefined; + try { + const buf = new Uint8Array(1500); + const n = (await f.read(buf)) ?? 0; + const head = buf.subarray(0, n); + if (!looksLikeText(head)) return undefined; + const text = new TextDecoder().decode(head).replace(/```/g, "ˋˋˋ"); + return "```\n" + text + (n === buf.length ? "\n…" : "") + "\n```"; + } finally { + f.close(); + } +} + +async function restore(i: Trashed): Promise<string> { + let dest = i.original; + if (await exists(dest)) dest = restoredName(dest); + if (await exists(dest)) throw new Error(`${t(i.original)} already exists`); + await exec("mkdir", ["-p", dirname(dest)]); + await exec("mv", ["-n", "-T", i.file, dest]); + if (await exists(i.file)) throw new Error("The item couldn't be moved back"); + await Deno.remove(`${TRASH}/info/${i.name}.trashinfo`).catch(() => {}); + return dest; +} + +async function deleteForever(i: Trashed) { + await Deno.remove(i.file, { recursive: true }); + await Deno.remove(`${TRASH}/info/${i.name}.trashinfo`).catch(() => {}); +} + +async function emptyTrash() { + try { + await exec("gio", ["trash", "--empty"]); + return; + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + // Without gio: the home trash only. + for (const dir of ["files", "info"]) { + for await (const e of Deno.readDir(`${TRASH}/${dir}`)) { + await Deno.remove(`${TRASH}/${dir}/${e.name}`, { recursive: true }).catch(() => {}); + } + } + await Deno.remove(`${TRASH}/directorysizes`).catch(() => {}); +} + +function emptyAction(count: number, bytes: number): Action { + return action("Empty Trash", async () => { + await emptyTrash(); + updateCommandMetadata({ subtitle: "Empty" }).catch(() => {}); + await toast(`Trash emptied, ${formatBytes(bytes)} freed`, { style: "success" }); + return refresh(); + }, { + icon: Icons.trash, + destructive: true, + section: "Danger", + shortcut: "<Ctrl><Shift>Delete", + confirm: `Delete ${count} item${count === 1 ? "" : "s"} (${formatBytes(bytes)}) for good?`, + }); +} + +async function item(i: Trashed, count: number, total: number): Promise<Item> { + const { icon, tint } = ICONS[i.kind]; + const where = dirname(i.original); + const md = await preview(i).catch(() => undefined); + const gone = !(await exists(where)); + return { + id: i.name, + title: basename(i.original), + subtitle: t(where), + icon: { source: icon, tint }, + section: daySection(i.deleted), + keywords: [i.original, i.kind, i.name], + accessories: [ + ...(i.isDir && i.count !== undefined + ? [{ text: `${i.count} item${i.count === 1 ? "" : "s"}`, color: "gray" as const }] + : []), + { text: formatBytes(i.size) }, + { text: when(i.deleted), color: "gray" as const, tooltip: "Deleted" }, + ], + detail: { + image: previewable(i.name) ? i.file : undefined, + stats: [ + { label: "Size", value: formatBytes(i.size) }, + ...(i.isDir && i.count !== undefined ? [{ label: "Items", value: i.count > 999 ? "999+" : i.count }] : []), + ], + markdown: md, + metadata: [ + meta.text("Original location", t(where), { icon: Icons.folder }), + ...(gone + ? [meta.text("Note", "That folder is gone: restoring recreates it", { icon: Icons.info, color: "orange" })] + : []), + meta.text("Deleted", i.deleted ? i.deleted.toLocaleString("en-GB") : "Unknown", { icon: Icons.clock }), + meta.tags("Kind", [{ text: i.kind, color: tint }]), + meta.separator(), + meta.progress("Of the trash", total ? i.size / total : 0), + ], + }, + actions: [ + action("Restore", async () => { + const dest = await restore(i); + await toast(`Restored to ${t(dest)}`, { style: "success" }); + return refresh(); + }, { icon: Icons.history }), + open(i.file, i.isDir ? "Open Folder" : "Open", { icon: Icons.external, shortcut: "<Ctrl>o" }), + action("Restore and Show", async () => { + const dest = await restore(i); + await toast(`Restored to ${t(dest)}`, { style: "success" }); + await openExternal(dirname(dest)); + return close(); + }, { icon: Icons.folder, shortcut: "<Ctrl>Return" }), + copy(i.original, "Copy Original Path", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(i.file, "Copy Path in Trash", { section: "Copy" }), + action("Delete Forever", async () => { + await deleteForever(i); + await toast(`${basename(i.original)} deleted`, { style: "success" }); + return refresh(); + }, { + icon: Icons.cross, + destructive: true, + section: "Danger", + shortcut: "<Ctrl>Delete", + confirm: `Delete ${basename(i.original)} (${formatBytes(i.size)}) for good? This can't be undone.`, + }), + emptyAction(count, total), + action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r", section: "View" }), + ], + }; +} + +let status = ""; + +export default list({ + placeholder: "Filter the trash by name or original folder…", + filter: { + options: [ + { value: "all", title: "All" }, + { value: "files", title: "Files" }, + { value: "folders", title: "Folders" }, + { value: "images", title: "Pictures & Videos" }, + ], + }, + empty: { title: "The trash is empty", description: "Deleted files land here until you empty it", icon: Icons.trash }, + status: () => status, + actions: [open("trash:///", "Open Trash in File Manager", { icon: Icons.folder })], + async items({ filter }) { + const all = await load(); + const total = all.reduce((s, i) => s + Math.max(0, i.size), 0); + status = summary(all.length, total) + + (all.length ? ` · oldest ${daySection(all.at(-1)?.deleted).toLowerCase()}` : ""); + updateCommandMetadata({ subtitle: summary(all.length, total) }).catch(() => {}); + const f = filter ?? "all"; + const shown = all.filter((i) => + f === "all" || (f === "folders" ? i.isDir : f === "files" ? !i.isDir : i.kind === "image" || i.kind === "video") + ); + return await Promise.all(shown.map((i) => item(i, all.length, total))); + }, +}); diff --git a/extensions/trash/lib.ts b/extensions/trash/lib.ts new file mode 100644 index 0000000..d59a2d1 --- /dev/null +++ b/extensions/trash/lib.ts @@ -0,0 +1,132 @@ +// The freedesktop.org trash: parsing .trashinfo files and sorting items by +// deletion day. Pure, tested in lib_test.ts. + +export interface TrashInfo { + /** Where the item came from, decoded. */ + path: string; + /** Local time, as written by the file manager. */ + deleted?: Date; +} + +/** A `.trashinfo` file: `Path=` is percent-encoded, `DeletionDate=` local time `YYYY-MM-DDThh:mm:ss`. */ +export function parseTrashInfo(text: string): TrashInfo | undefined { + if (!/^\[Trash Info\]/m.test(text)) return undefined; + const path = text.match(/^Path=(.*)$/m)?.[1]?.trim(); + if (!path) return undefined; + const date = text.match(/^DeletionDate=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/m); + return { + path: decodePath(path), + deleted: date ? new Date(+date[1], +date[2] - 1, +date[3], +date[4], +date[5], +date[6]) : undefined, + }; +} + +export function decodePath(p: string): string { + try { + return decodeURIComponent(p); + } catch { + return p; + } +} + +/** `~/.local/share/Trash/directorysizes`: `<bytes> <mtime> <percent-encoded name>` per line. */ +export function parseDirectorySizes(text: string): Map<string, number> { + const out = new Map<string, number>(); + for (const line of text.split("\n")) { + const m = line.match(/^(\d+) \d+ (.+)$/); + if (m) out.set(decodePath(m[2]), Number(m[1])); + } + return out; +} + +// --------------------------------------------------------------------------- +// Kinds + +export type Kind = "folder" | "image" | "video" | "audio" | "document" | "archive" | "code" | "text" | "file"; + +const EXT: Record<Exclude<Kind, "folder" | "file">, RegExp> = { + image: /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|tiff?)$/i, + video: /\.(mp4|mkv|webm|mov|avi|m4v)$/i, + audio: /\.(mp3|flac|ogg|opus|wav|m4a|aac)$/i, + document: /\.(pdf|docx?|odt|xlsx?|ods|pptx?|odp|rtf|epub)$/i, + archive: /\.(zip|tar|gz|tgz|xz|zst|bz2|7z|rar|deb|rpm|appimage|iso)$/i, + code: /\.(ts|js|py|rs|go|c|h|cpp|java|rb|sh|json|toml|ya?ml|html|css)$/i, + text: /\.(txt|md|log|csv|ini|conf)$/i, +}; + +export function kindOf(name: string, isDir: boolean): Kind { + if (isDir) return "folder"; + for (const [k, re] of Object.entries(EXT)) if (re.test(name)) return k as Kind; + return "file"; +} + +/** Pictures the launcher can show in the side panel. */ +export const previewable = (name: string) => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name); +export const textual = (k: Kind) => k === "text" || k === "code"; + +/** Whether bytes look like text: no NUL, mostly printable. */ +export function looksLikeText(bytes: Uint8Array): boolean { + if (!bytes.length) return true; + let odd = 0; + for (const b of bytes) { + if (b === 0) return false; + if (b < 9 || (b > 13 && b < 32)) odd++; + } + return odd / bytes.length < 0.05; +} + +// --------------------------------------------------------------------------- +// Days + +const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + +/** "Today", "Yesterday", "Monday 26 October", "12 March 2025". */ +export function daySection(date: Date | undefined, now = new Date()): string { + if (!date) return "Unknown Date"; + const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + const opts: Intl.DateTimeFormatOptions = days < 7 + ? { weekday: "long", day: "numeric", month: "long" } + : date.getFullYear() === now.getFullYear() + ? { day: "numeric", month: "long" } + : { day: "numeric", month: "long", year: "numeric" }; + return date.toLocaleDateString("en-GB", opts); +} + +/** "14:03", or "3 Oct, 14:03" outside today. */ +export function when(date: Date | undefined, now = new Date()): string { + if (!date) return ""; + const time = date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }); + return startOfDay(date) === startOfDay(now) + ? time + : `${date.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}, ${time}`; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +export const tilde = (p: string, home: string) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p); +export const dirname = (p: string) => p.replace(/\/+$/, "").split("/").slice(0, -1).join("/") || "/"; +export const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() ?? p; + +/** "N items · size" */ +export const summary = (count: number, bytes: number) => + count ? `${count} item${count === 1 ? "" : "s"} · ${formatBytes(bytes)}` : "Empty"; + +/** A name for restoring next to an existing file: `report (restored).pdf`. */ +export function restoredName(path: string): string { + const dir = dirname(path); + const name = basename(path); + const dot = name.lastIndexOf("."); + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; + return `${dir === "/" ? "" : dir}/${stem} (restored)${ext}`; +} diff --git a/extensions/trash/lib_test.ts b/extensions/trash/lib_test.ts new file mode 100644 index 0000000..6e372d7 --- /dev/null +++ b/extensions/trash/lib_test.ts @@ -0,0 +1,67 @@ +import { assertEquals } from "@std/assert"; +import { + daySection, + decodePath, + formatBytes, + kindOf, + looksLikeText, + parseDirectorySizes, + parseTrashInfo, + previewable, + restoredName, + summary, + when, +} from "./lib.ts"; + +Deno.test("trashinfo files", () => { + const info = parseTrashInfo( + "[Trash Info]\nPath=/home/ada/Documents/Rapport%20final%20%C3%A9t%C3%A9.pdf\nDeletionDate=2026-10-27T14:03:11\n", + ); + assertEquals(info?.path, "/home/ada/Documents/Rapport final été.pdf"); + assertEquals(info?.deleted, new Date(2026, 9, 27, 14, 3, 11)); + // Relative paths (trash on another drive) and missing dates. + assertEquals(parseTrashInfo("[Trash Info]\nPath=photos/cat.jpg\n"), { path: "photos/cat.jpg", deleted: undefined }); + assertEquals(parseTrashInfo("Path=/x\n"), undefined); + assertEquals(decodePath("100%"), "100%"); +}); + +Deno.test("directorysizes cache", () => { + const sizes = parseDirectorySizes("4096 1729000000 old%20project\n123456789 1729000001 node_modules\nbroken line\n"); + assertEquals(sizes.get("old project"), 4096); + assertEquals(sizes.get("node_modules"), 123456789); + assertEquals(sizes.size, 2); +}); + +Deno.test("kinds and previews", () => { + assertEquals(kindOf("cat.JPG", false), "image"); + assertEquals(kindOf("notes.md", false), "text"); + assertEquals(kindOf("app.AppImage", false), "archive"); + assertEquals(kindOf("photos", true), "folder"); + assertEquals(kindOf("README", false), "file"); + assertEquals(previewable("a.webp"), true); + assertEquals(previewable("a.heic"), false); + assertEquals(looksLikeText(new TextEncoder().encode("hello\nworld\té")), true); + assertEquals(looksLikeText(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 1])), false); +}); + +Deno.test("sections by deletion day", () => { + const now = new Date(2026, 9, 28, 10, 0); // Wednesday 28 October 2026 + assertEquals(daySection(new Date(2026, 9, 28, 0, 5), now), "Today"); + assertEquals(daySection(new Date(2026, 9, 27, 23, 59), now), "Yesterday"); + assertEquals(daySection(new Date(2026, 9, 26, 9), now), "Monday 26 October"); + assertEquals(daySection(new Date(2026, 8, 3), now), "3 September"); + assertEquals(daySection(new Date(2025, 11, 31), now), "31 December 2025"); + assertEquals(daySection(undefined, now), "Unknown Date"); + assertEquals(when(new Date(2026, 9, 28, 9, 7), now), "09:07"); + assertEquals(when(new Date(2026, 9, 3, 18, 30), now), "3 Oct, 18:30"); +}); + +Deno.test("names and summary", () => { + assertEquals(restoredName("/home/ada/report.pdf"), "/home/ada/report (restored).pdf"); + assertEquals(restoredName("/home/ada/.bashrc"), "/home/ada/.bashrc (restored)"); + assertEquals(restoredName("/home/ada/photos"), "/home/ada/photos (restored)"); + assertEquals(summary(3, 5 * 1024 * 1024), "3 items · 5.0 MB"); + assertEquals(summary(1, 10), "1 item · 10 B"); + assertEquals(summary(0, 0), "Empty"); + assertEquals(formatBytes(1536), "1.5 KB"); +}); diff --git a/extensions/trello/api.ts b/extensions/trello/api.ts new file mode 100644 index 0000000..ec07123 --- /dev/null +++ b/extensions/trello/api.ts @@ -0,0 +1,46 @@ +// Calls to the Trello REST API with the user's key and token. + +import type { Item } from "@backslash/api"; +import { type Account, account, API, AuthError, setupItem, trelloError } from "./lib.ts"; + +export async function trello<T>( + a: Account, + path: string, + options: { method?: string; params?: Record<string, string | number | boolean | undefined>; signal?: AbortSignal } = + {}, +): Promise<T> { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(options.params ?? {})) if (v !== undefined) params.set(k, String(v)); + const sep = path.includes("?") ? "&" : "?"; + let res: Response; + try { + res = await fetch(`${API}${path}${params.size ? `${sep}${params}` : ""}`, { + method: options.method ?? "GET", + signal: options.signal, + headers: { + // The documented header form, which keeps the token out of URLs and logs. + Authorization: `OAuth oauth_consumer_key="${a.key}", oauth_token="${a.token}"`, + Accept: "application/json", + }, + }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`Trello is unreachable: ${(e as Error).message}`); + } + const text = await res.text(); + if (!res.ok) throw trelloError(res.status, text); + return (text ? JSON.parse(text) : undefined) as T; +} + +export const CARD_FIELDS = + "name,desc,due,dueComplete,start,idList,idBoard,idMembers,labels,url,shortUrl,pos,dateLastActivity,badges"; +export const MEMBER_FIELDS = "fullName,username,avatarUrl,initials"; + +export async function guarded(prefs: Record<string, unknown>, load: (a: Account) => Promise<Item[]>): Promise<Item[]> { + try { + return await load(account(prefs)); + } catch (e) { + if (e instanceof AuthError) return [setupItem(e, prefs)]; + throw e; + } +} diff --git a/extensions/trello/backslash.toml b/extensions/trello/backslash.toml new file mode 100644 index 0000000..7e61893 --- /dev/null +++ b/extensions/trello/backslash.toml @@ -0,0 +1,44 @@ +[extension] +id = "trello" +title = "Trello" +description = "Your boards and cards with labels, due dates and members; move, complete, archive and create cards" +version = "1.0.0" +author = "Backslash" +icon = "bs-kanban" +license = "MIT" + +# The REST API, members' avatars and the scaled board backgrounds. +[permissions] +net = ["api.trello.com", "trello-members.s3.amazonaws.com", "trello-backgrounds.s3.amazonaws.com"] + +[[commands]] +name = "boards" +title = "Trello Boards" +description = "Starred boards first; Enter shows the cards, by list" +icon = "bs-kanban" +keywords = ["trello", "boards", "kanban", "cards", "lists", "tableaux", "cartes"] + +[[commands]] +name = "my-cards" +title = "My Trello Cards" +description = "Cards you are a member of, by due date" +icon = "bs-square-check" +keywords = ["trello", "cards", "my cards", "due", "tasks", "todo", "mes cartes", "échéance", "tâches"] + +[[commands]] +name = "create-card" +title = "Create Trello Card" +description = "Pick a board and list, a title, a due date" +icon = "bs-plus" +keywords = ["trello", "new", "card", "add", "create", "nouvelle carte", "ajouter", "créer"] + +[[preferences]] +name = "apiKey" +title = "API key" +description = "The API key of a Power-Up of yours: trello.com/power-ups/admin → New → API key" + +[[preferences]] +name = "token" +title = "Token" +description = "Generated from your API key page (“Token” link), or by opening the setup row in Trello Boards" +type = "secret" diff --git a/extensions/trello/commands/boards.ts b/extensions/trello/commands/boards.ts new file mode 100644 index 0000000..30ff05d --- /dev/null +++ b/extensions/trello/commands/boards.ts @@ -0,0 +1,45 @@ +import { type Context, list } from "@backslash/api"; +import { guarded, trello } from "../api.ts"; +import type { Board, Organization } from "../lib.ts"; +import { boardItem } from "../views.ts"; + +let counts = { boards: 0, starred: 0 }; + +export default (ctx: Context) => + list({ + placeholder: "Filter boards…", + empty: { title: "No open board", description: "Create a board in Trello first.", icon: "bs-kanban" }, + status: (items) => + items[0]?.id === "setup" + ? "Not connected" + : `${counts.boards} board${counts.boards === 1 ? "" : "s"}${ + counts.starred ? ` · ${counts.starred} starred` : "" + }`, + items: ({ preferences, signal }) => + guarded(preferences, async (a) => { + const [boards, orgs] = await Promise.all([ + trello<Board[]>(a, "/members/me/boards", { + params: { + filter: "open", + fields: "name,desc,url,shortUrl,starred,idOrganization,dateLastActivity,prefs", + lists: "open", + list_fields: "name", + }, + signal, + }), + trello<Organization[]>(a, "/members/me/organizations", { params: { fields: "displayName" }, signal }) + .catch(() => [] as Organization[]), + ]); + const names = new Map(orgs.map((o) => [o.id, o.displayName])); + counts = { boards: boards.length, starred: boards.filter((b) => b.starred).length }; + const workspace = (b: Board) => + b.idOrganization ? names.get(b.idOrganization) ?? "Other Workspace" : "Personal"; + return [...boards] + .sort((x, y) => + Number(!!y.starred) - Number(!!x.starred) || + (x.starred ? 0 : workspace(x).localeCompare(workspace(y))) || + (y.dateLastActivity ?? "").localeCompare(x.dateLastActivity ?? "") + ) + .map((b) => boardItem(a, b, ctx.preferences, workspace(b))); + }), + }); diff --git a/extensions/trello/commands/create-card.ts b/extensions/trello/commands/create-card.ts new file mode 100644 index 0000000..54e2df5 --- /dev/null +++ b/extensions/trello/commands/create-card.ts @@ -0,0 +1,14 @@ +import { type Context, detail } from "@backslash/api"; +import { account, AuthError, setupItem } from "../lib.ts"; +import { cardForm } from "../views.ts"; + +// Text typed before opening the command becomes the title. +export default async (ctx: Context) => { + try { + return await cardForm(account(ctx.preferences), {}, { standalone: true, title: ctx.query.trim() }); + } catch (e) { + if (!(e instanceof AuthError)) throw e; + const setup = setupItem(e, ctx.preferences); + return detail({ title: "Connect Trello", markdown: setup.detail!.markdown!, actions: setup.actions }); + } +}; diff --git a/extensions/trello/commands/my-cards.ts b/extensions/trello/commands/my-cards.ts new file mode 100644 index 0000000..9fa521c --- /dev/null +++ b/extensions/trello/commands/my-cards.ts @@ -0,0 +1,59 @@ +import { list, updateCommandMetadata } from "@backslash/api"; +import { CARD_FIELDS, guarded, MEMBER_FIELDS, trello } from "../api.ts"; +import { type Board, type Card, cardSection, dueOf, sortByDue } from "../lib.ts"; +import { live } from "../live.ts"; +import { cardItem } from "../views.ts"; + +let summary = ""; + +export default list({ + placeholder: "Filter your cards by title, label, board, list…", + filter: { + options: [ + { value: "all", title: "All My Cards" }, + { value: "due", title: "Due This Week" }, + ], + }, + empty: { title: "No card here", description: "Cards you are a member of show up here.", icon: "bs-square-check" }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : summary, + items: ({ preferences, signal, filter }) => + live(() => + guarded(preferences, async (a) => { + const [cards, boards] = await Promise.all([ + trello<Card[]>(a, "/members/me/cards", { + params: { filter: "visible", fields: CARD_FIELDS, members: true, member_fields: MEMBER_FIELDS }, + signal, + }), + trello<Board[]>(a, "/members/me/boards", { + params: { filter: "all", fields: "name", lists: "all", list_fields: "name" }, + signal, + }), + ]); + const boardName = new Map(boards.map((b) => [b.id, b.name])); + const listName = new Map(boards.flatMap((b) => (b.lists ?? []).map((l) => [l.id, l.name] as const))); + const now = new Date(); + const sections = new Map(cards.map((c) => [c.id, cardSection(c, now)])); + const overdue = cards.filter((c) => sections.get(c.id) === "Overdue").length; + const today = cards.filter((c) => sections.get(c.id) === "Today").length; + summary = [ + `${cards.length} card${cards.length === 1 ? "" : "s"}`, + overdue ? `${overdue} overdue` : "", + today ? `${today} due today` : "", + ].filter(Boolean).join(" · "); + updateCommandMetadata({ + subtitle: [overdue ? `${overdue} overdue` : "", today ? `${today} due today` : ""].filter(Boolean) + .join(" · ") || null, + }).catch(() => {}); + const week = now.getTime() + 7 * 86_400_000; + const shown = filter === "due" + ? cards.filter((c) => !c.dueComplete && c.due && dueOf(c)!.getTime() < week) + : cards; + return sortByDue(shown).map((c) => + cardItem(a, c, { + board: boardName.get(c.idBoard), + list: listName.get(c.idList), + section: sections.get(c.id), + }, now) + ); + }), signal), +}); diff --git a/extensions/trello/lib.ts b/extensions/trello/lib.ts new file mode 100644 index 0000000..31e4012 --- /dev/null +++ b/extensions/trello/lib.ts @@ -0,0 +1,456 @@ +// Trello: account, response types and pure mapping (colors, due dates, metadata). + +import { type Color, Icons, type Item, meta, type Metadata, open } from "@backslash/api"; + +export const API = "https://api.trello.com/1"; +export const KEY_URL = "https://trello.com/power-ups/admin"; + +/** The key or token is missing or refused: the list shows how to connect. */ +export class AuthError extends Error {} + +export interface Account { + key: string; + token: string; +} + +export function account(prefs: Record<string, unknown>): Account { + const key = String(prefs.apiKey ?? "").trim(); + const token = String(prefs.token ?? "").trim(); + if (!key) throw new AuthError("Connect your Trello account"); + if (!/^[0-9a-f]{32}$/i.test(key)) { + throw new AuthError("This doesn't look like a Trello API key (32 hexadecimal characters)"); + } + if (!token) throw new AuthError("Generate a token for your API key"); + return { key, token }; +} + +/** The page where Trello issues a token for the user's own key. */ +export function authorizeUrl(key: string): string { + const params = new URLSearchParams({ + expiration: "never", + scope: "read,write", + response_type: "token", + name: "Backslash", + key, + }); + return `https://trello.com/1/authorize?${params}`; +} + +/** Trello answers errors in plain text ("invalid token", "unauthorized card permission requested"). */ +export function trelloError(status: number, text: string): Error { + const message = text.trim().slice(0, 200); + if (status === 401 && /invalid (key|token)|expired token|unauthorized permission/i.test(message)) { + return new AuthError("Trello rejected the API key or token"); + } + if (status === 401) return new Error(`Trello refused it: ${message || "unauthorized"}`); + if (status === 404) return new Error("Not found on Trello: it may have been deleted or moved"); + if (status === 429) return new Error("Trello is rate limiting: try again in a few seconds"); + return new Error(`Trello answered ${status}${message ? `: ${message}` : ""}`); +} + +// --------------------------------------------------------------------------- +// Types (only the fields used) + +export interface Label { + id: string; + name: string; + color: string | null; +} + +export interface Member { + id: string; + fullName: string; + username: string; + avatarUrl?: string | null; + initials?: string; +} + +export interface Card { + id: string; + name: string; + desc: string; + due: string | null; + dueComplete: boolean; + start?: string | null; + idList: string; + idBoard: string; + idMembers: string[]; + labels: Label[]; + url: string; + shortUrl?: string; + pos: number; + dateLastActivity: string; + members?: Member[]; + badges?: { + comments?: number; + attachments?: number; + checkItems?: number; + checkItemsChecked?: number; + description?: boolean; + }; +} + +export interface List { + id: string; + name: string; + pos: number; + closed?: boolean; +} + +export interface Board { + id: string; + name: string; + desc?: string; + url: string; + shortUrl?: string; + starred?: boolean; + closed?: boolean; + idOrganization?: string | null; + dateLastActivity?: string | null; + prefs?: { + backgroundColor?: string | null; + backgroundTopColor?: string | null; + backgroundImageScaled?: { width: number; height: number; url: string }[] | null; + }; + lists?: List[]; +} + +export interface Organization { + id: string; + displayName: string; +} + +// --------------------------------------------------------------------------- +// Colors + +/** Trello's label colors (also `green_dark`, `sky_light`…) in the launcher's palette. */ +export function labelColor(color: string | null | undefined): Color { + const base = (color ?? "").replace(/_(dark|light)$/, ""); + const map: Record<string, Color> = { + green: "green", + yellow: "yellow", + orange: "orange", + red: "red", + purple: "purple", + blue: "blue", + sky: "teal", + lime: "green", + pink: "pink", + black: "gray", + }; + return map[base] ?? "gray"; +} + +/** The palette color closest to a `#rrggbb` color. */ +export function colorOf(hex: string | null | undefined): Color { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex ?? ""); + if (!m) return "gray"; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16) / 255); + const max = Math.max(r, g, b), min = Math.min(r, g, b); + if (max - min < 0.12) return "gray"; + let h = 0; + if (max === r) h = ((g - b) / (max - min)) % 6; + else if (max === g) h = (b - r) / (max - min) + 2; + else h = (r - g) / (max - min) + 4; + const hue = (h * 60 + 360) % 360; + if (hue < 15 || hue >= 345) return "red"; + if (hue < 40) return "orange"; + if (hue < 65) return "yellow"; + if (hue < 160) return "green"; + if (hue < 195) return "teal"; + if (hue < 250) return "blue"; + if (hue < 300) return "purple"; + return "pink"; +} + +export function boardColor(board: Board): Color { + return colorOf(board.prefs?.backgroundTopColor ?? board.prefs?.backgroundColor); +} + +/** A scaled background around 600px wide, only from the host in `net`. */ +export function boardImage(board: Board): string | undefined { + const scaled = (board.prefs?.backgroundImageScaled ?? []) + .filter((s) => s.url.startsWith("https://trello-backgrounds.s3.amazonaws.com/")); + if (!scaled.length) return undefined; + const fit = [...scaled].sort((a, b) => Math.abs(a.width - 640) - Math.abs(b.width - 640)); + return fit[0].url; +} + +/** `https://trello-members.s3.amazonaws.com/<id>/<hash>` → its 50px picture. */ +export function avatarOf(member: Member): string { + const url = member.avatarUrl ?? ""; + if (url.startsWith("https://trello-members.s3.amazonaws.com/")) return /\.png$/.test(url) ? url : `${url}/50.png`; + return member.fullName || member.username; +} + +// --------------------------------------------------------------------------- +// Cards + +export const dueOf = (card: Card) => card.due ? new Date(card.due) : undefined; + +export function cardSection(card: Card, now = new Date()): string { + if (card.dueComplete) return "Completed"; + return dueBucket(dueOf(card), now); +} + +/** Due first (soonest first), then the most recently active. */ +export function sortByDue(cards: Card[]): Card[] { + const key = (c: Card) => c.dueComplete ? 2 : c.due ? 0 : 1; + return [...cards].sort((a, b) => + key(a) - key(b) || (a.due ?? "").localeCompare(b.due ?? "") || + b.dateLastActivity.localeCompare(a.dateLastActivity) + ); +} + +/** Cards of a board by list position, then card position. */ +export function sortByList(cards: Card[], lists: List[]): Card[] { + const order = new Map(lists.map((l) => [l.id, l.pos])); + return [...cards].sort((a, b) => (order.get(a.idList) ?? 1e15) - (order.get(b.idList) ?? 1e15) || a.pos - b.pos); +} + +export function checklist(card: Card): { done: number; total: number } { + return { done: card.badges?.checkItemsChecked ?? 0, total: card.badges?.checkItems ?? 0 }; +} + +export function dueColor(card: Card, now = new Date()): Color | undefined { + const due = dueOf(card); + if (!due) return undefined; + if (card.dueComplete) return "green"; + if (due < now) return "red"; + if (due.getTime() - now.getTime() < 86_400_000) return "orange"; + return undefined; +} + +export function cardIcon(card: Card): { source: string; tint: Color } { + if (card.dueComplete) return { source: Icons.squareCheck, tint: "green" }; + const first = card.labels.find((l) => l.color); + return { source: Icons.square, tint: first ? labelColor(first.color) : "gray" }; +} + +/** Labels without a name show their color, as on the board. */ +export const labelText = (l: Label) => l.name || (l.color ?? "label").replace(/_/g, " "); + +export function cardMetadata( + card: Card, + where: { board?: string; list?: string }, + now = new Date(), +): Metadata[] { + const lines: Metadata[] = []; + if (where.board) lines.push(meta.text("Board", where.board, { icon: Icons.kanban })); + if (where.list) lines.push(meta.text("List", where.list, { icon: Icons.layers })); + const due = dueOf(card); + if (due) { + lines.push(meta.text("Due", `${shortDue(due, now)}${card.dueComplete ? " · complete" : ""}`, { + icon: card.dueComplete ? Icons.squareCheck : Icons.calendar, + color: dueColor(card, now), + })); + } + if (card.start) lines.push(meta.text("Start", shortDue(new Date(card.start), now, false), { icon: Icons.calendar })); + if (card.labels.length) { + lines.push(meta.tags("Labels", card.labels.map((l) => ({ text: labelText(l), color: labelColor(l.color) })))); + } + for (const [i, m] of (card.members ?? []).entries()) { + lines.push(meta.person(i === 0 ? "Members" : "", m.fullName || m.username, avatarOf(m))); + } + const list = checklist(card); + if (list.total) lines.push(meta.progress("Checklist", list.done / list.total, `${list.done} of ${list.total}`)); + if (card.badges?.comments) lines.push(meta.text("Comments", card.badges.comments, { icon: Icons.comment })); + if (card.badges?.attachments) lines.push(meta.text("Attachments", card.badges.attachments, { icon: Icons.file })); + lines.push(meta.separator()); + lines.push(meta.text("Last activity", shortDue(new Date(card.dateLastActivity), now, false))); + lines.push(meta.link("Link", (card.shortUrl ?? card.url).replace(/^https:\/\//, ""), card.shortUrl ?? card.url)); + return lines; +} + +/** The first paragraphs of the description, for the side panel. */ +export function excerpt(text: string | null | undefined, max = 500): string | undefined { + const t = (text ?? "").trim(); + if (!t) return undefined; + return t.length > max ? `${t.slice(0, max).trimEnd()}…` : t; +} + +export function setupItem(error: AuthError, prefs: Record<string, unknown>): Item { + const key = String(prefs.apiKey ?? "").trim(); + const hasKey = /^[0-9a-f]{32}$/i.test(key); + return { + id: "setup", + title: error.message, + subtitle: hasKey ? "Your API key is set: generate a token for it" : "An API key and a token from Trello", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect Trello", + "", + "Trello gives personal access with an **API key** and a **token**:", + "", + "1. Open **trello.com/power-ups/admin**, create a Power-Up (any name, your workspace),", + " then **API key → Generate a new API key**.", + "2. Paste the key in **Settings → Extensions → Trello → Configure → API key**.", + "3. Come back here: Enter opens Trello's page that **generates a token** for that key", + " (read and write, no expiry). Paste it in **Token**.", + "", + "You can revoke the token any time in Trello → Settings → Applications.", + ].join("\n"), + }, + actions: hasKey + ? [ + open(authorizeUrl(key), "Generate Token", { icon: Icons.key }), + open(KEY_URL, "Open Power-Up Admin", { icon: Icons.settings }), + ] + : [open(KEY_URL, "Create an API Key", { icon: Icons.key })], + }; +} + +// --------------------------------------------------------------------------- +// Dates + +/** `YYYY-MM-DD` in local time. */ +export function localDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Midnight of a `YYYY-MM-DD` day, local time. */ +export function dayOf(date: string): Date { + const [y, m, d] = date.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + out.setDate(out.getDate() + n); + return out; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + +/** A day typed by a person, or undefined: today, tomorrow, friday, next week, +3d, 2026-11-03, 3 Nov. */ +function parseDay(text: string, now: Date): Date | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " "); + const today = addDays(now, 0); + if (/^(today|tod|tonight)$/.test(t)) return today; + if (/^(tomorrow|tmr|tom|tmrw)$/.test(t)) return addDays(now, 1); + if (t === "day after tomorrow") return addDays(now, 2); + if (t === "next week") return addDays(now, ((8 - now.getDay()) % 7) || 7); + if (t === "weekend" || t === "this weekend") return addDays(now, now.getDay() === 6 ? 0 : (6 - now.getDay()) || 6); + const rel = /^(?:in )?\+?(\d{1,3}) ?(d|days?|w|weeks?)$/.exec(t); + if (rel) return addDays(now, Number(rel[1]) * (rel[2].startsWith("w") ? 7 : 1)); + const wd = /^(?:next |this |on )?([a-z]{3,9})$/.exec(t); + if (wd) { + const i = WEEKDAYS.findIndex((w) => w.startsWith(wd[1]) && wd[1].length >= 3); + if (i >= 0) return addDays(now, ((i - now.getDay() + 7) % 7) || 7); + } + const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t); + if (iso) return valid(new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])), Number(iso[3])); + const dm = /^(\d{1,2}) ([a-z]{3,9})\.?(?: (\d{4}))?$/.exec(t) ?? + swap(/^([a-z]{3,9})\.? (\d{1,2})(?:,? (\d{4}))?$/.exec(t)); + if (dm) { + const month = MONTHS.indexOf(dm[2].slice(0, 3)); + if (month < 0) return undefined; + const day = Number(dm[1]); + let d = new Date(dm[3] ? Number(dm[3]) : now.getFullYear(), month, day); + if (!dm[3] && d < today) d = new Date(now.getFullYear() + 1, month, day); + return valid(d, day); + } + return undefined; +} + +function swap(m: RegExpExecArray | null): string[] | null { + return m ? [m[0], m[2], m[1], m[3]] : null; +} + +function valid(d: Date, day: number): Date | undefined { + return isNaN(d.getTime()) || d.getDate() !== day ? undefined : d; +} + +/** A time of day: 15:00, 3pm, 3:30pm, 15h, 15h30. */ +function parseTime(text: string): { h: number; m: number } | undefined { + const t = text.trim().toLowerCase(); + const m = /^(\d{1,2})(?:[:h.](\d{2}))? ?(am|pm|h)?$/.exec(t); + if (!m || (!m[2] && !m[3])) return undefined; + let h = Number(m[1]); + const min = Number(m[2] ?? 0); + if (m[3] === "pm" && h < 12) h += 12; + if (m[3] === "am" && h === 12) h = 0; + if (h > 23 || min > 59) return undefined; + return { h, m: min }; +} + +export interface When { + date: Date; + /** False for a whole day. */ + time: boolean; +} + +/** + * A due date typed in a form: "tomorrow", "fri 3pm", "next week", "+3d", + * "2026-11-03 14:30", "3 Nov". Empty → undefined; unreadable → throws. + */ +export function parseWhen(text: string, now = new Date()): When | undefined { + const t = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/ at /, " "); + if (!t || t === "none" || t === "no date") return undefined; + const words = t.split(" "); + // The time is the last word or two ("3 pm"). + for (const n of [2, 1]) { + if (words.length < n) continue; + const time = parseTime(words.slice(-n).join("")); + if (!time) continue; + const rest = words.slice(0, -n).join(" ").replace(/ at$/, ""); + let day = rest ? parseDay(rest, now) : addDays(now, 0); + if (!day) break; + const date = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.h, time.m); + // "3pm" alone, already past: tomorrow. + if (!rest && date <= now) { + day = addDays(now, 1); + date.setFullYear(day.getFullYear(), day.getMonth(), day.getDate()); + } + return { date, time: true }; + } + const day = parseDay(t, now); + if (day) return { date: day, time: false }; + throw new Error(`Can't read the date “${text.trim()}”: try today, tomorrow, friday 3pm, +3d or 2026-11-03`); +} + +/** "Overdue", "Today", "Tomorrow", "This Week", "Later" or "No Due Date". */ +export function dueBucket(due: Date | undefined, now = new Date(), time = true): string { + if (!due) return "No Due Date"; + const today = localDate(now); + const day = localDate(due); + if (time ? due < now && day <= today : day < today) return "Overdue"; + if (day === today) return "Today"; + if (day === localDate(addDays(now, 1))) return "Tomorrow"; + if (due < addDays(now, 7)) return "This Week"; + return "Later"; +} + +export const BUCKETS = ["Overdue", "Today", "Tomorrow", "This Week", "Later", "No Due Date"]; + +/** "today 15:00", "tomorrow", "Fri", "Nov 3", "Nov 3, 2027". */ +export function shortDue(due: Date, now = new Date(), time = true): string { + const clock = time ? ` ${due.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}` : ""; + const day = localDate(due); + if (day === localDate(now)) return `today${clock}`; + if (day === localDate(addDays(now, 1))) return `tomorrow${clock}`; + if (day === localDate(addDays(now, -1))) return `yesterday${clock}`; + const days = (dayOf(day).getTime() - dayOf(localDate(now)).getTime()) / 86_400_000; + if (days > 0 && days < 7) return `${due.toLocaleDateString("en", { weekday: "short" })}${clock}`; + const sameYear = due.getFullYear() === now.getFullYear(); + return due.toLocaleDateString("en", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }) }) + + clock; +} + +/** The choices of a "Change Due Date" submenu, as `YYYY-MM-DD`. */ +export function dueChoices(now = new Date()): { title: string; date: string | null }[] { + const monday = addDays(now, ((8 - now.getDay()) % 7) || 7); + return [ + { title: "Today", date: localDate(now) }, + { title: "Tomorrow", date: localDate(addDays(now, 1)) }, + { + title: `Next Week (${monday.toLocaleDateString("en", { weekday: "short", day: "numeric", month: "short" })})`, + date: localDate(monday), + }, + { title: "In Two Weeks", date: localDate(addDays(now, 14)) }, + { title: "No Due Date", date: null }, + ]; +} diff --git a/extensions/trello/lib_test.ts b/extensions/trello/lib_test.ts new file mode 100644 index 0000000..818b2c5 --- /dev/null +++ b/extensions/trello/lib_test.ts @@ -0,0 +1,204 @@ +import { assert, assertEquals, assertInstanceOf, assertStringIncludes, assertThrows } from "@std/assert"; +import { + account, + AuthError, + authorizeUrl, + avatarOf, + type Board, + boardColor, + boardImage, + type Card, + cardIcon, + cardMetadata, + cardSection, + colorOf, + dueBucket, + dueChoices, + dueColor, + labelColor, + labelText, + parseWhen, + setupItem, + shortDue, + sortByDue, + sortByList, + trelloError, +} from "./lib.ts"; + +const KEY = "0123456789abcdef0123456789abcdef"; + +Deno.test("account: key and token", () => { + assertThrows(() => account({}), AuthError, "Connect"); + assertThrows(() => account({ apiKey: "nope" }), AuthError, "API key"); + assertThrows(() => account({ apiKey: KEY }), AuthError, "token"); + assertEquals(account({ apiKey: ` ${KEY} `, token: " ATTA123 " }), { key: KEY, token: "ATTA123" }); + const url = new URL(authorizeUrl(KEY)); + assertEquals(url.host, "trello.com"); + assertEquals(url.searchParams.get("key"), KEY); + assertEquals(url.searchParams.get("scope"), "read,write"); + assertEquals(url.searchParams.get("expiration"), "never"); +}); + +Deno.test("errors", () => { + assertInstanceOf(trelloError(401, "invalid token"), AuthError); + assertInstanceOf(trelloError(401, "invalid key"), AuthError); + assert(!(trelloError(401, "unauthorized card permission requested") instanceof AuthError)); + assertStringIncludes(trelloError(404, "The requested resource was not found.").message, "Not found"); + assertEquals(trelloError(500, "").message, "Trello answered 500"); +}); + +Deno.test("setup row: with a key, Enter generates the token", () => { + const none = setupItem(new AuthError("Connect your Trello account"), {}); + assertEquals(none.actions![0].title, "Create an API Key"); + const withKey = setupItem(new AuthError("Generate a token for your API key"), { apiKey: KEY }); + assertEquals(withKey.actions![0].title, "Generate Token"); + assertStringIncludes((withKey.actions![0] as { target: string }).target, KEY); +}); + +Deno.test("colors", () => { + assertEquals(labelColor("green"), "green"); + assertEquals(labelColor("sky_dark"), "teal"); + assertEquals(labelColor("black_light"), "gray"); + assertEquals(labelColor(null), "gray"); + assertEquals(colorOf("#0079BF"), "blue"); + assertEquals(colorOf("#D29034"), "orange"); + assertEquals(colorOf("#838C91"), "gray"); + assertEquals(boardColor({ id: "b", name: "B", url: "", prefs: { backgroundColor: "#519839" } }), "green"); + assertEquals(labelText({ id: "l", name: "", color: "purple_dark" }), "purple dark"); +}); + +Deno.test("board background: the scaled image closest to 640px from the allowed host", () => { + const board: Board = { + id: "b", + name: "B", + url: "", + prefs: { + backgroundImageScaled: [ + { width: 140, height: 100, url: "https://trello-backgrounds.s3.amazonaws.com/x/140x100/a.jpg" }, + { width: 640, height: 480, url: "https://trello-backgrounds.s3.amazonaws.com/x/640x480/a.jpg" }, + { width: 1920, height: 1080, url: "https://trello-backgrounds.s3.amazonaws.com/x/1920x1080/a.jpg" }, + ], + }, + }; + assertEquals(boardImage(board), "https://trello-backgrounds.s3.amazonaws.com/x/640x480/a.jpg"); + assertEquals( + boardImage({ + ...board, + prefs: { backgroundImageScaled: [{ width: 640, height: 480, url: "https://images.unsplash.com/x" }] }, + }), + undefined, + ); + assertEquals( + avatarOf({ id: "m", fullName: "Ada", username: "ada", avatarUrl: "https://trello-members.s3.amazonaws.com/1/abc" }), + "https://trello-members.s3.amazonaws.com/1/abc/50.png", + ); + assertEquals(avatarOf({ id: "m", fullName: "Ada Lovelace", username: "ada", avatarUrl: null }), "Ada Lovelace"); +}); + +const NOW = new Date(2026, 9, 27, 14, 10, 0); + +function card(over: Partial<Card>): Card { + return { + id: "c", + name: "Card", + desc: "", + due: null, + dueComplete: false, + idList: "l1", + idBoard: "b1", + idMembers: [], + labels: [], + url: "https://trello.com/c/abc/1-card", + shortUrl: "https://trello.com/c/abc", + pos: 1, + dateLastActivity: "2026-10-20T10:00:00.000Z", + ...over, + }; +} + +Deno.test("cards: sections, order, icon, due color", () => { + const late = card({ id: "late", due: new Date(2026, 9, 26, 12).toISOString() }); + const soon = card({ id: "soon", due: new Date(2026, 9, 27, 18).toISOString() }); + const done = card({ id: "done", due: new Date(2026, 9, 20).toISOString(), dueComplete: true }); + const none = card({ id: "none", labels: [{ id: "x", name: "Bug", color: "red" }] }); + assertEquals(cardSection(late, NOW), "Overdue"); + assertEquals(cardSection(soon, NOW), "Today"); + assertEquals(cardSection(done, NOW), "Completed"); + assertEquals(cardSection(none, NOW), "No Due Date"); + assertEquals(sortByDue([none, done, soon, late]).map((c) => c.id), ["late", "soon", "none", "done"]); + assertEquals(cardIcon(none).tint, "red"); + assertEquals(cardIcon(done).tint, "green"); + assertEquals(dueColor(late, NOW), "red"); + assertEquals(dueColor(soon, NOW), "orange"); + assertEquals(dueColor(none, NOW), undefined); + const lists = [{ id: "l2", name: "Doing", pos: 2 }, { id: "l1", name: "To Do", pos: 1 }]; + const a = card({ id: "a", idList: "l2", pos: 5 }); + const b = card({ id: "b", idList: "l1", pos: 9 }); + const c = card({ id: "c", idList: "l1", pos: 3 }); + assertEquals(sortByList([a, b, c], lists).map((x) => x.id), ["c", "b", "a"]); +}); + +Deno.test("card metadata", () => { + const lines = cardMetadata( + card({ + due: new Date(2026, 9, 28, 17).toISOString(), + labels: [{ id: "x", name: "Design", color: "purple" }], + members: [{ id: "m", fullName: "Ada Lovelace", username: "ada", avatarUrl: null }], + badges: { checkItems: 4, checkItemsChecked: 3, comments: 2 }, + }), + { board: "Roadmap", list: "Doing" }, + NOW, + ); + const by = (label: string) => lines.find((l) => l.label === label); + assertEquals(by("Board")?.value, "Roadmap"); + assertEquals(by("Due")?.value, "tomorrow 17:00"); + assertEquals(by("Labels")?.tags, [{ text: "Design", color: "purple" }]); + assertEquals(by("Members")?.avatar, "Ada Lovelace"); + assertEquals(by("Checklist")?.progress, 0.75); + assertEquals(by("Comments")?.value, "2"); + assertEquals(by("Link")?.link, "https://trello.com/c/abc"); +}); + +// A Tuesday afternoon, local time. +const T = new Date(2026, 9, 27, 14, 10, 0); +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +Deno.test("parseWhen: words, weekdays, relative days, times", () => { + assertEquals(parseWhen("", T), undefined); + assertEquals(parseWhen("today", T), { date: at(2026, 10, 27), time: false }); + assertEquals(parseWhen("Tomorrow", T), { date: at(2026, 10, 28), time: false }); + assertEquals(parseWhen("friday", T), { date: at(2026, 10, 30), time: false }); + // Today is a Tuesday: "tue" is next week's. + assertEquals(parseWhen("tue", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("next week", T), { date: at(2026, 11, 2), time: false }); + assertEquals(parseWhen("+3d", T), { date: at(2026, 10, 30), time: false }); + assertEquals(parseWhen("in 2 weeks", T), { date: at(2026, 11, 10), time: false }); + assertEquals(parseWhen("2026-11-03", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("3 Nov", T), { date: at(2026, 11, 3), time: false }); + assertEquals(parseWhen("jan 5", T), { date: at(2027, 1, 5), time: false }); + assertEquals(parseWhen("tomorrow 3pm", T), { date: at(2026, 10, 28, 15), time: true }); + assertEquals(parseWhen("fri at 9:30am", T), { date: at(2026, 10, 30, 9, 30), time: true }); + assertEquals(parseWhen("2026-11-03 14:30", T), { date: at(2026, 11, 3, 14, 30), time: true }); + assertEquals(parseWhen("17h", T), { date: at(2026, 10, 27, 17), time: true }); + // Already past today: tomorrow. + assertEquals(parseWhen("9am", T), { date: at(2026, 10, 28, 9), time: true }); + assertThrows(() => parseWhen("someday", T), Error, "Can't read the date"); + assertThrows(() => parseWhen("2026-02-31", T), Error); +}); + +Deno.test("due buckets and short dates", () => { + assertEquals(dueBucket(undefined, T), "No Due Date"); + assertEquals(dueBucket(at(2026, 10, 26, 18), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 9), T), "Overdue"); + assertEquals(dueBucket(at(2026, 10, 27, 0), T, false), "Today"); + assertEquals(dueBucket(at(2026, 10, 27, 18), T), "Today"); + assertEquals(dueBucket(at(2026, 10, 28, 18), T), "Tomorrow"); + assertEquals(dueBucket(at(2026, 11, 1, 18), T), "This Week"); + assertEquals(dueBucket(at(2026, 11, 20), T), "Later"); + assertEquals(shortDue(at(2026, 10, 27, 18), T), "today 18:00"); + assertEquals(shortDue(at(2026, 10, 28), T, false), "tomorrow"); + assertEquals(shortDue(at(2026, 10, 30, 9), T), "Fri 09:00"); + assertEquals(shortDue(at(2026, 11, 20), T, false), "Nov 20"); + assertEquals(shortDue(at(2027, 1, 20), T, false), "Jan 20, 2027"); + assertEquals(dueChoices(T).map((c) => c.date), ["2026-10-27", "2026-10-28", "2026-11-02", "2026-11-10", null]); +}); diff --git a/extensions/trello/live.ts b/extensions/trello/live.ts new file mode 100644 index 0000000..8b10fca --- /dev/null +++ b/extensions/trello/live.ts @@ -0,0 +1,24 @@ +// Views of this extension run in one process: a change made from one view +// (a new status) reloads the lists still open. + +const changes = new EventTarget(); + +export function notifyChanged() { + changes.dispatchEvent(new Event("change")); +} + +/** Yields `load()`, then again after each change, until `signal` aborts. */ +export async function* live<T>(load: () => Promise<T>, signal: AbortSignal): AsyncGenerator<T> { + while (!signal.aborted) { + yield await load(); + await new Promise<void>((resolve) => { + const done = () => { + changes.removeEventListener("change", done); + signal.removeEventListener("abort", done); + resolve(); + }; + changes.addEventListener("change", done); + signal.addEventListener("abort", done); + }); + } +} diff --git a/extensions/trello/views.ts b/extensions/trello/views.ts new file mode 100644 index 0000000..b801a14 --- /dev/null +++ b/extensions/trello/views.ts @@ -0,0 +1,296 @@ +// Items, actions and forms shared by the commands. + +import { + type Action, + action, + copy, + detail, + field, + form, + Icons, + type Item, + list, + type ListSpec, + meta, + open, + pop, + storage, + submenu, + toast, +} from "@backslash/api"; +import { CARD_FIELDS, guarded, MEMBER_FIELDS, trello } from "./api.ts"; +import { + type Account, + avatarOf, + type Board, + boardColor, + boardImage, + type Card, + cardIcon, + cardMetadata, + checklist, + dueColor, + dueOf, + excerpt, + labelColor, + labelText, + type List, + type Member, + parseWhen, + shortDue, + sortByList, +} from "./lib.ts"; +import { live, notifyChanged } from "./live.ts"; + +async function updateCard(a: Account, card: Card, params: Record<string, string | boolean>, message: string) { + await trello(a, `/cards/${card.id}`, { method: "PUT", params }); + notifyChanged(); + await toast(message, { style: "success" }); +} + +export function cardActions(a: Account, card: Card, where: { list?: string }): Action[] { + const moveTo = submenu("Move to List", async () => { + const lists = await trello<List[]>(a, `/boards/${card.idBoard}/lists`, { + params: { filter: "open", fields: "name,pos" }, + }); + return lists.filter((l) => l.id !== card.idList).map((l) => + action(l.name, () => updateCard(a, card, { idList: l.id, pos: "top" }, `Moved to ${l.name}`), { + icon: Icons.arrowRight, + }) + ); + }, { icon: Icons.layers, shortcut: "<Ctrl>m", section: "Card" }); + const due = dueOf(card); + return [ + open(card.url, "Open in Trello"), + moveTo, + ...(due + ? [action( + card.dueComplete ? "Mark Due Incomplete" : "Mark Due Complete", + () => + updateCard( + a, + card, + { dueComplete: !card.dueComplete }, + card.dueComplete ? "Due marked incomplete" : "Due complete", + ), + { icon: card.dueComplete ? Icons.square : Icons.squareCheck, shortcut: "<Ctrl>d", section: "Card" }, + )] + : []), + action("New Card in This List…", () => cardForm(a, { idList: card.idList, listName: where.list }), { + icon: Icons.plus, + shortcut: "<Ctrl>n", + section: "Card", + }), + copy(card.shortUrl ?? card.url, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(`[${card.name}](${card.shortUrl ?? card.url})`, "Copy as Markdown Link", { section: "Copy" }), + copy(card.name, "Copy Title", { shortcut: "<Ctrl>period", section: "Copy" }), + action("Archive Card", () => updateCard(a, card, { closed: true }, "Card archived"), { + icon: Icons.archive, + destructive: true, + confirm: `Archive “${card.name}”?`, + shortcut: "<Ctrl><Shift>Delete", + section: "Danger", + }), + ]; +} + +export function cardItem( + a: Account, + card: Card, + where: { board?: string; list?: string; section?: string }, + now = new Date(), + options: { avatars?: boolean } = {}, +): Item { + const due = dueOf(card); + const list = checklist(card); + const members = card.members ?? []; + return { + id: card.id, + title: card.name, + // In a board, sections are its lists; elsewhere, where the card is. + subtitle: where.section === where.list ? undefined : [where.board, where.list].filter(Boolean).join(" › "), + icon: cardIcon(card), + section: where.section, + keywords: [ + ...card.labels.map(labelText), + ...members.map((m) => m.fullName), + where.board ?? "", + where.list ?? "", + ].filter(Boolean), + accessories: [ + ...card.labels.slice(0, 2).map((l) => ({ tag: labelText(l), color: labelColor(l.color) })), + ...(list.total + ? [{ + icon: Icons.squareCheck, + text: `${list.done}/${list.total}`, + color: list.done === list.total ? "green" as const : undefined, + tooltip: "Checklist", + }] + : []), + ...(card.badges?.comments + ? [{ icon: Icons.comment, text: String(card.badges.comments), tooltip: "Comments" }] + : []), + ...(due ? [{ icon: Icons.clock, text: shortDue(due, now), color: dueColor(card, now), tooltip: "Due" }] : []), + ...(options.avatars === false ? [] : members).slice(0, 2).map((m: Member) => ({ + avatar: avatarOf(m), + tooltip: m.fullName, + })), + ], + detail: { + markdown: excerpt(card.desc), + metadata: cardMetadata(card, where, now), + }, + actions: cardActions(a, card, where), + }; +} + +/** The cards of a board, by list, live after a move or an archive. */ +export function boardCards(a: Account, board: Board, prefs: Record<string, unknown>): ListSpec { + let me = ""; + let total = 0; + return list({ + title: board.name, + placeholder: `Filter cards of ${board.name}…`, + filter: { options: [{ value: "all", title: "All Cards" }, { value: "mine", title: "My Cards" }] }, + empty: { title: "No card here", icon: Icons.kanban }, + status: (items) => items[0]?.id === "setup" ? "Not connected" : `${items.length} of ${total} cards`, + actions: [ + action("New Card…", () => cardForm(a, { idBoard: board.id }), { icon: Icons.plus, shortcut: "<Ctrl>n" }), + open(board.url, "Open Board in Trello"), + ], + items: ({ signal, filter }) => + live(() => + guarded(prefs, async () => { + const [lists, cards, self] = await Promise.all([ + trello<List[]>(a, `/boards/${board.id}/lists`, { params: { filter: "open", fields: "name,pos" }, signal }), + trello<Card[]>(a, `/boards/${board.id}/cards/open`, { + params: { fields: CARD_FIELDS, members: true, member_fields: MEMBER_FIELDS }, + signal, + }), + me + ? Promise.resolve({ id: me }) + : trello<{ id: string }>(a, "/members/me", { params: { fields: "id" }, signal }), + ]); + me = self.id; + total = cards.length; + const names = new Map(lists.map((l) => [l.id, l.name])); + const shown = filter === "mine" ? cards.filter((c) => c.idMembers.includes(me)) : cards; + return sortByList(shown, lists).map((c) => { + const name = names.get(c.idList) ?? "Other"; + return cardItem(a, c, { board: board.name, list: name, section: name }); + }); + }), signal), + }); +} + +export function boardItem(a: Account, board: Board, prefs: Record<string, unknown>, workspace?: string): Item { + const image = boardImage(board); + return { + id: board.id, + title: board.name, + subtitle: board.starred ? workspace : board.desc?.split("\n")[0] || undefined, + icon: { source: Icons.kanban, tint: boardColor(board) }, + section: board.starred ? "Starred" : workspace ?? "Boards", + keywords: [workspace ?? ""].filter(Boolean), + accessories: [ + ...(board.starred ? [{ icon: { source: Icons.starFilled, tint: "yellow" as const }, tooltip: "Starred" }] : []), + ...(board.dateLastActivity + ? [{ text: shortDue(new Date(board.dateLastActivity), new Date(), false), tooltip: "Last activity" }] + : []), + ], + detail: { + image, + markdown: excerpt(board.desc), + metadata: [ + ...(workspace ? [meta.text("Workspace", workspace, { icon: Icons.home })] : []), + ...(board.lists ? [meta.text("Lists", board.lists.length, { icon: Icons.layers })] : []), + ...(board.dateLastActivity + ? [meta.text("Last activity", shortDue(new Date(board.dateLastActivity), new Date(), false))] + : []), + meta.separator(), + meta.link("Link", (board.shortUrl ?? board.url).replace(/^https:\/\//, ""), board.url), + ], + }, + actions: [ + action("Show Cards", () => boardCards(a, board, prefs), { icon: Icons.layers }), + open(board.url, "Open in Trello"), + action("New Card…", () => cardForm(a, { idBoard: board.id }), { icon: Icons.plus, shortcut: "<Ctrl>n" }), + copy(board.shortUrl ?? board.url, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + ], + }; +} + +/** Every open list of every open board, as "Board › List". */ +export async function allLists(a: Account): Promise<{ value: string; title: string; board: string }[]> { + const boards = await trello<Board[]>(a, "/members/me/boards", { + params: { filter: "open", fields: "name,starred", lists: "open", list_fields: "name,pos" }, + }); + const sorted = [...boards].sort((x, y) => Number(!!y.starred) - Number(!!x.starred) || x.name.localeCompare(y.name)); + return sorted.flatMap((b) => + [...(b.lists ?? [])].sort((x, y) => x.pos - y.pos).map((l) => ({ + value: l.id, + title: `${b.name} › ${l.name}`, + board: b.id, + })) + ); +} + +/** A new card; `standalone` when the form is the command itself (no list to go back to). */ +export async function cardForm( + a: Account, + where: { idList?: string; idBoard?: string; listName?: string } = {}, + options: { standalone?: boolean; title?: string } = {}, +) { + const lists = await allLists(a); + if (!lists.length) throw new Error("No open Trello board with a list: create one in Trello first"); + const last = await storage.get<string>("lastList").catch(() => undefined); + const initial = lists.find((l) => l.value === where.idList) ?? lists.find((l) => l.board === where.idBoard) ?? + lists.find((l) => l.value === last) ?? lists[0]; + return form({ + title: "New Trello card", + submitTitle: "Create Card", + fields: [ + field.dropdown("list", "List", lists.map(({ value, title }) => ({ value, title })), { value: initial.value }), + field.text("name", "Title", { required: true, placeholder: "What needs doing?", value: options.title ?? "" }), + field.textArea("desc", "Description", { placeholder: "Markdown" }), + field.text("due", "Due", { placeholder: "tomorrow 5pm, friday, +3d, 2026-11-03…" }), + field.checkbox("top", "Put it at the top of the list", { value: true }), + field.checkbox("me", "Add me to the card", { value: true }), + ], + async submit(values) { + const when = parseWhen(String(values.due ?? "")); + const due = when ? (when.time ? when.date : new Date(when.date.setHours(12, 0, 0, 0))) : undefined; + const me = values.me ? (await trello<{ id: string }>(a, "/members/me", { params: { fields: "id" } })).id : ""; + const card = await trello<Card>(a, "/cards", { + method: "POST", + params: { + idList: String(values.list), + name: String(values.name).trim(), + desc: String(values.desc ?? "").trim() || undefined, + due: due?.toISOString(), + pos: values.top ? "top" : "bottom", + idMembers: me || undefined, + }, + }); + await storage.set("lastList", String(values.list)).catch(() => {}); + notifyChanged(); + const where = lists.find((l) => l.value === values.list)?.title ?? ""; + await toast(`Card created in ${where}`, { style: "success" }); + if (!options.standalone) return pop(); + return detail({ + title: card.name, + markdown: `# ${card.name}\n\nCreated in **${where}**.`, + metadata: [ + meta.text("List", where, { icon: Icons.layers }), + ...(due ? [meta.text("Due", shortDue(due), { icon: Icons.calendar })] : []), + meta.link("Link", card.shortUrl ?? card.url, card.shortUrl ?? card.url), + ], + status: "Card created", + actions: [ + open(card.url, "Open in Trello"), + copy(card.shortUrl ?? card.url, "Copy Link", { shortcut: "<Ctrl><Shift>c" }), + ], + }); + }, + }); +} diff --git a/extensions/wikipedia/api.ts b/extensions/wikipedia/api.ts new file mode 100644 index 0000000..dae6803 --- /dev/null +++ b/extensions/wikipedia/api.ts @@ -0,0 +1,65 @@ +// Requests to Wikipedia. Wikimedia asks API clients to identify themselves. + +import { + articleUrl, + type DayEntry, + type LangLink, + langLinksUrl, + onThisDayUrl, + type Page, + parseArticle, + parseLangLinks, + parseOnThisDay, + parseSearch, + parseSummary, + randomUrl, + searchUrl, +} from "./lib.ts"; + +const HEADERS = { + "User-Agent": "Backslash-Wikipedia/1.0 (https://github.com/backslash-app/plugins)", + "Api-User-Agent": "Backslash-Wikipedia/1.0 (https://github.com/backslash-app/plugins)", + Accept: "application/json", +}; + +export class NotFound extends Error {} + +async function get(url: string, signal?: AbortSignal): Promise<unknown> { + let res: Response; + try { + res = await fetch(url, { headers: HEADERS, signal }); + } catch (e) { + if (signal?.aborted) throw e; + if (e instanceof TypeError && /dns|lookup|resolve/i.test(e.message)) { + const lang = new URL(url).hostname.split(".")[0]; + throw new Error(`No Wikipedia in “${lang}”, or you are offline`); + } + throw new Error(`Wikipedia can't be reached: ${(e as Error).message}`); + } + if (res.status === 404) { + await res.body?.cancel(); + throw new NotFound("Not found"); + } + if (!res.ok) { + await res.body?.cancel(); + throw new Error(`Wikipedia answered ${res.status} ${res.statusText}`); + } + const json = await res.json() as { error?: { info?: string } }; + if (json?.error) throw new Error(`Wikipedia: ${json.error.info ?? "error"}`); + return json; +} + +export const search = async (lang: string, text: string, signal?: AbortSignal): Promise<Page[]> => + parseSearch(lang, await get(searchUrl(lang, text), signal)); + +export const article = async (lang: string, title: string, signal?: AbortSignal): Promise<Page | undefined> => + parseArticle(lang, await get(articleUrl(lang, title), signal)); + +export const langLinks = async (lang: string, title: string): Promise<LangLink[]> => + parseLangLinks(await get(langLinksUrl(lang, title))); + +export const random = async (lang: string, signal?: AbortSignal): Promise<Page> => + parseSummary(lang, await get(randomUrl(lang), signal) as Parameters<typeof parseSummary>[1]); + +export const onThisDay = async (lang: string, date: Date, signal?: AbortSignal): Promise<DayEntry[]> => + parseOnThisDay(lang, await get(onThisDayUrl(lang, date), signal)); diff --git a/extensions/wikipedia/backslash.toml b/extensions/wikipedia/backslash.toml new file mode 100644 index 0000000..483f006 --- /dev/null +++ b/extensions/wikipedia/backslash.toml @@ -0,0 +1,78 @@ +[extension] +id = "wikipedia" +title = "Wikipedia" +description = "Search Wikipedia in your language, read summaries and whole articles, and discover random pages" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# The MediaWiki and REST APIs of each language edition. The main languages +# are listed exactly, because pictures are only shown from exact hosts; +# *.wikipedia.org lets the other languages (typed as "eo: …") work too, without +# pictures. Thumbnails come from upload.wikimedia.org. +[permissions] +net = [ + "en.wikipedia.org", + "fr.wikipedia.org", + "de.wikipedia.org", + "es.wikipedia.org", + "it.wikipedia.org", + "pt.wikipedia.org", + "nl.wikipedia.org", + "pl.wikipedia.org", + "ja.wikipedia.org", + "zh.wikipedia.org", + "ru.wikipedia.org", + "sv.wikipedia.org", + "uk.wikipedia.org", + "ar.wikipedia.org", + "ca.wikipedia.org", + "cs.wikipedia.org", + "fi.wikipedia.org", + "ko.wikipedia.org", + "no.wikipedia.org", + "tr.wikipedia.org", + "vi.wikipedia.org", + "he.wikipedia.org", + "id.wikipedia.org", + "fa.wikipedia.org", + "simple.wikipedia.org", + "*.wikipedia.org", + "upload.wikimedia.org", +] +env = ["LANG", "LC_ALL", "LC_MESSAGES"] + +[[commands]] +name = "search" +title = "Search Wikipedia" +description = "Articles with their summary and picture; type “fr: …” for another language" +icon = "bs-book" +keywords = ["wikipedia", "wiki", "encyclopedia", "encyclopédie", "article", "search", "chercher", "définition"] + +[[commands]] +name = "random" +title = "Random Wikipedia Article" +description = "Something new to read" +icon = "bs-shuffle" +keywords = ["wikipedia", "random", "aléatoire", "hasard", "article", "discover", "découvrir"] + +[[commands]] +name = "on-this-day" +title = "On This Day" +description = "Events, births and deaths of today in history, from Wikipedia" +icon = "bs-calendar" +keywords = ["wikipedia", "history", "histoire", "today", "aujourd'hui", "éphéméride", "anniversary", "births", "deaths"] + +[[preferences]] +name = "language" +title = "Language" +description = "A Wikipedia language code: en, fr, de, es, it, pt, ja… Empty: your system's language" +default = "" + +[[preferences]] +name = "readHere" +title = "Read articles in the launcher" +description = "Enter opens the article in Backslash instead of the browser (Ctrl+Enter does the other)" +type = "boolean" +default = false diff --git a/extensions/wikipedia/commands/on-this-day.ts b/extensions/wikipedia/commands/on-this-day.ts new file mode 100644 index 0000000..c254efb --- /dev/null +++ b/extensions/wikipedia/commands/on-this-day.ts @@ -0,0 +1,72 @@ +import { type Color, type Context, Icons, type Item, list, type ListSpec, meta } from "@backslash/api"; +import { onThisDay } from "../api.ts"; +import { type DayKind, languageName, ON_THIS_DAY, truncate } from "../lib.ts"; +import { defaultLanguage, pageActions, pageMetadata } from "../ui.ts"; + +const KINDS: { value: DayKind; title: string; icon: string; tint: Color }[] = [ + { value: "selected", title: "Selected", icon: Icons.starFilled, tint: "yellow" }, + { value: "events", title: "Events", icon: Icons.calendar, tint: "blue" }, + { value: "births", title: "Births", icon: Icons.person, tint: "green" }, + { value: "deaths", title: "Deaths", icon: Icons.person, tint: "gray" }, + { value: "holidays", title: "Holidays", icon: Icons.sparkle, tint: "pink" }, +]; + +/** Events, births, deaths and holidays of today, from Wikipedia's feed. */ +export function dayView(preferred: string): ListSpec { + // The feed exists in a few languages only; English otherwise. + const lang = ON_THIS_DAY.includes(preferred) ? preferred : "en"; + const today = new Date(); + const date = today.toLocaleDateString(lang === "fr" ? "fr-FR" : "en-GB", { day: "numeric", month: "long" }); + return list({ + title: `On This Day · ${date}`, + placeholder: "Filter events, births, deaths…", + filter: { options: KINDS.map(({ value, title }) => ({ value, title })), default: "selected" }, + status: (items) => `${items.length} entries · ${date} · ${languageName(lang)} Wikipedia`, + empty: { title: "Nothing for today", icon: Icons.calendar }, + async items({ filter, signal }) { + const kind = KINDS.find((k) => k.value === filter) ?? KINDS[0]; + const entries = (await onThisDay(lang, today, signal)).filter((e) => e.kind === kind.value); + return entries.map((e, i): Item => { + const main = e.pages.find((p) => p.image) ?? e.pages[0]; + const ago = e.year !== undefined ? today.getFullYear() - e.year : undefined; + return { + id: `${kind.value}:${i}`, + title: truncate(e.text, 140), + subtitle: undefined, + icon: main?.image?.url ?? { source: kind.icon, tint: kind.tint }, + keywords: e.pages.map((p) => p.title), + accessories: e.year !== undefined ? [{ tag: String(e.year) }] : [], + detail: { + image: main?.image?.url, + stats: e.year !== undefined + ? [{ label: "Year", value: e.year }, ...(ago! > 0 ? [{ label: "Years ago", value: ago! }] : [])] + : [], + markdown: [ + e.text, + main + ? `**${main.title}**${main.description ? `: *${main.description}*` : ""}\n\n${ + truncate(main.extract, 700) + }` + : "", + ] + .filter(Boolean).join("\n\n"), + metadata: main + ? [ + ...pageMetadata(main), + ...(e.pages.length > 1 + ? [ + meta.separator(), + meta.tags("Also", e.pages.filter((p) => p !== main).slice(0, 6).map((p) => p.title)), + ] + : []), + ] + : [], + }, + actions: main ? pageActions(main) : [], + }; + }); + }, + }); +} + +export default (ctx: Context) => dayView(defaultLanguage(ctx)); diff --git a/extensions/wikipedia/commands/random.ts b/extensions/wikipedia/commands/random.ts new file mode 100644 index 0000000..310fa64 --- /dev/null +++ b/extensions/wikipedia/commands/random.ts @@ -0,0 +1,25 @@ +import { action, type Context, detail, type DetailSpec, Icons, replace } from "@backslash/api"; +import { random } from "../api.ts"; +import { extractToMarkdown, languageName } from "../lib.ts"; +import { articleView, defaultLanguage, pageActions, pageMetadata, remember } from "../ui.ts"; + +/** A random article's summary, with "Another One" and "Read Here". */ +export async function randomView(lang: string): Promise<DetailSpec> { + const p = await random(lang); + await remember(p).catch(() => {}); + const actions = pageActions(p, { readFirst: true }); + return detail({ + title: p.title, + markdown: `# ${p.title}\n\n${p.description ? `*${p.description}*\n\n` : ""}${extractToMarkdown(p.extract)}`, + metadata: pageMetadata(p), + status: `Random · ${languageName(lang)} Wikipedia`, + actions: [ + actions[0], + action("Another One", async () => replace(await randomView(lang)), { icon: Icons.shuffle, shortcut: "<Ctrl>n" }), + ...actions.slice(1), + action("Read Here", () => articleView(p.lang, p.title), { icon: Icons.book }), + ].filter((a, i, all) => all.findIndex((b) => b.title === a.title) === i), + }); +} + +export default (ctx: Context) => randomView(defaultLanguage(ctx)); diff --git a/extensions/wikipedia/commands/search.ts b/extensions/wikipedia/commands/search.ts new file mode 100644 index 0000000..2a24e68 --- /dev/null +++ b/extensions/wikipedia/commands/search.ts @@ -0,0 +1,84 @@ +import { action, type Context, Icons, type Item, list, open, refresh } from "@backslash/api"; +import { search } from "../api.ts"; +import { languageName, splitLanguage } from "../lib.ts"; +import { articleView, defaultLanguage, forget, pageItem, recentItem, recents } from "../ui.ts"; +import { randomView } from "./random.ts"; +import { dayView } from "./on-this-day.ts"; + +let shown = ""; + +export default (ctx: Context) => { + const fallback = defaultLanguage(ctx); + const readFirst = !!(ctx.preferences as { readHere?: boolean }).readHere; + return list({ + placeholder: `Search ${languageName(fallback)} Wikipedia… (fr: Paris for another language)`, + status: () => shown, + empty: { + title: "No article found", + description: "Check the spelling, or search another language with a prefix: “de: Berlin”", + icon: Icons.book, + }, + async search(query, { signal }) { + const { lang, text, override } = splitLanguage(query, fallback); + if (!text) { + shown = `${languageName(lang)} Wikipedia`; + const recent = await recents(); + const start: Item[] = [ + { + id: "random", + title: "Random Article", + subtitle: `Something to read on ${languageName(lang)} Wikipedia`, + icon: { source: Icons.shuffle, tint: "purple" }, + section: "Discover", + actions: [action("Random Article", () => randomView(lang), { icon: Icons.shuffle })], + }, + { + id: "on-this-day", + title: "On This Day", + subtitle: new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long" }), + icon: { source: Icons.calendar, tint: "blue" }, + section: "Discover", + actions: [action("On This Day", () => dayView(lang), { icon: Icons.calendar })], + }, + ]; + return [ + ...recent.filter((r) => !override || r.lang === lang).map((r) => recentItem(r, readFirst)), + ...start, + ...(recent.length + ? [ + <Item> { + id: "clear", + title: "Clear Recently Viewed", + icon: { source: Icons.trash, tint: "gray" }, + section: "Discover", + actions: [ + action("Clear Recently Viewed", async () => { + await forget(undefined); + return refresh(); + }, { icon: Icons.trash, destructive: true, confirm: "Forget every recently viewed article?" }), + ], + }, + ] + : []), + ]; + } + const pages = await search(lang, text, signal); + shown = `${pages.length} result${pages.length === 1 ? "" : "s"} · ${languageName(lang)} Wikipedia`; + const exact = pages.findIndex((p) => p.title.toLowerCase() === text.toLowerCase()); + if (exact > 0) pages.unshift(...pages.splice(exact, 1)); + const items = pages.map((p) => pageItem(p, undefined, readFirst)); + const searchUrl = `https://${lang}.wikipedia.org/w/index.php?search=${encodeURIComponent(text)}&fulltext=1`; + items.push({ + id: "search-site", + title: `Search “${text}” on Wikipedia`, + subtitle: `${languageName(lang)} · in the browser`, + icon: { source: Icons.search, tint: "gray" }, + actions: [ + open(searchUrl, "Search in Browser", { icon: Icons.globe }), + action(`Read “${text}” Here`, () => articleView(lang, text), { icon: Icons.book }), + ], + }); + return items; + }, + }); +}; diff --git a/extensions/wikipedia/lib.ts b/extensions/wikipedia/lib.ts new file mode 100644 index 0000000..b363ff9 --- /dev/null +++ b/extensions/wikipedia/lib.ts @@ -0,0 +1,365 @@ +// Wikipedia URLs, response mapping and plain text → markdown. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Languages + +/** Languages whose hosts are listed exactly in the manifest, so their pictures show. */ +export const LANGUAGES: Record<string, string> = { + en: "English", + fr: "Français", + de: "Deutsch", + es: "Español", + it: "Italiano", + pt: "Português", + nl: "Nederlands", + pl: "Polski", + ja: "日本語", + zh: "中文", + ru: "Русский", + sv: "Svenska", + uk: "Українська", + ar: "العربية", + ca: "Català", + cs: "Čeština", + fi: "Suomi", + ko: "한국어", + no: "Norsk", + tr: "Türkçe", + vi: "Tiếng Việt", + he: "עברית", + id: "Bahasa Indonesia", + fa: "فارسی", + simple: "Simple English", +}; + +/** A Wikipedia language code: "en", "fr", "zh-yue", "simple". */ +export const isLanguage = (code: string) => /^[a-z]{2,3}(?:-[a-z]{2,8})*$|^simple$/.test(code); + +/** The preference, else the locale ("fr_FR.UTF-8" → "fr"), else English. */ +export function preferredLanguage(pref: string | undefined, locale: string | undefined): string { + const p = (pref ?? "").trim().toLowerCase(); + if (p && isLanguage(p)) return p; + const l = (locale ?? "").toLowerCase().match(/^([a-z]{2,3})(?:[_.@-]|$)/)?.[1]; + return l && l !== "c" && isLanguage(l) ? l : "en"; +} + +/** "fr: Tour Eiffel", "de:Berlin" → the language and the rest. */ +export function splitLanguage(query: string, fallback: string): { lang: string; text: string; override: boolean } { + const m = query.match(/^\s*([a-z]{2,3}(?:-[a-z]{2,8})?|simple)\s*:\s*(.*)$/i); + if (m && isLanguage(m[1].toLowerCase())) return { lang: m[1].toLowerCase(), text: m[2].trim(), override: true }; + return { lang: fallback, text: query.trim(), override: false }; +} + +export const languageName = (code: string) => LANGUAGES[code] ?? code; + +// --------------------------------------------------------------------------- +// URLs + +export const host = (lang: string) => `${lang}.wikipedia.org`; + +/** A title as it appears in URLs: underscores, percent-encoded. */ +export const titleKey = (title: string) => encodeURIComponent(title.replace(/ /g, "_")); + +export const pageUrl = (lang: string, title: string) => `https://${host(lang)}/wiki/${titleKey(title)}`; + +const COMMON = { action: "query", format: "json", formatversion: "2", redirects: "1", origin: "*" }; + +/** One request: search results with their intro, picture and description. */ +export function searchUrl(lang: string, text: string, limit = 12): string { + const p = new URLSearchParams({ + ...COMMON, + generator: "search", + gsrsearch: text, + gsrlimit: String(limit), + gsrnamespace: "0", + prop: "extracts|pageimages|description|info", + exintro: "1", + explaintext: "1", + exlimit: String(limit), + piprop: "thumbnail", + pithumbsize: "480", + pilicense: "any", + pilimit: String(limit), + inprop: "url", + }); + return `https://${host(lang)}/w/api.php?${p}`; +} + +/** The whole article as plain text with "== Section ==" headings. */ +export function articleUrl(lang: string, title: string): string { + const p = new URLSearchParams({ + ...COMMON, + titles: title, + prop: "extracts|pageimages|description|info", + explaintext: "1", + exsectionformat: "wiki", + piprop: "thumbnail", + pithumbsize: "640", + pilicense: "any", + inprop: "url", + }); + return `https://${host(lang)}/w/api.php?${p}`; +} + +/** The same article in other languages. */ +export function langLinksUrl(lang: string, title: string): string { + const p = new URLSearchParams({ + ...COMMON, + titles: title, + prop: "langlinks", + lllimit: "500", + llprop: "url|autonym", + }); + return `https://${host(lang)}/w/api.php?${p}`; +} + +export const randomUrl = (lang: string) => `https://${host(lang)}/api/rest_v1/page/random/summary`; +export const summaryUrl = (lang: string, title: string) => + `https://${host(lang)}/api/rest_v1/page/summary/${titleKey(title)}`; +export const onThisDayUrl = (lang: string, date: Date) => + `https://${host(lang)}/api/rest_v1/feed/onthisday/all/${String(date.getMonth() + 1).padStart(2, "0")}/${ + String(date.getDate()).padStart(2, "0") + }`; + +/** Languages with an "On this day" feed. */ +export const ON_THIS_DAY = ["en", "de", "fr", "sv", "pt", "ru", "es", "ar", "bs", "uk", "it", "tr", "zh"]; + +// --------------------------------------------------------------------------- +// Pages + +export interface Page { + lang: string; + title: string; + description?: string; + extract: string; + url: string; + image?: { url: string; width?: number; height?: number }; + /** Size of the wikitext in bytes. */ + length?: number; + /** ISO date of the last edit. */ + edited?: string; + disambiguation?: boolean; + coordinates?: { lat: number; lon: number }; +} + +/** "//upload.wikimedia.org/…" → "https://upload.wikimedia.org/…". */ +export const absolute = (url: string | undefined) => (url?.startsWith("//") ? `https:${url}` : url); + +interface ApiPage { + pageid?: number; + title: string; + index?: number; + missing?: boolean; + invalid?: boolean; + extract?: string; + description?: string; + thumbnail?: { source: string; width?: number; height?: number }; + fullurl?: string; + canonicalurl?: string; + length?: number; + touched?: string; + pageprops?: Record<string, string>; +} + +function fromApi(lang: string, p: ApiPage): Page { + return { + lang, + title: p.title, + description: p.description || undefined, + extract: (p.extract ?? "").trim(), + url: p.canonicalurl ?? p.fullurl ?? pageUrl(lang, p.title), + image: p.thumbnail + ? { url: absolute(p.thumbnail.source)!, width: p.thumbnail.width, height: p.thumbnail.height } + : undefined, + length: p.length, + edited: p.touched, + disambiguation: /disambiguation|homonymie|begriffsklärung|desambiguación|disambigua/i.test(p.description ?? "") || + undefined, + }; +} + +/** `action=query&generator=search` → pages in search order. */ +export function parseSearch(lang: string, json: unknown): Page[] { + const pages = ((json as { query?: { pages?: ApiPage[] } }).query?.pages ?? []) + .filter((p) => !p.missing && !p.invalid); + return pages.sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((p) => fromApi(lang, p)); +} + +/** `action=query&titles=…&prop=extracts` → the page, if it exists. */ +export function parseArticle(lang: string, json: unknown): Page | undefined { + const p = (json as { query?: { pages?: ApiPage[] } }).query?.pages?.[0]; + return p && !p.missing && !p.invalid ? fromApi(lang, p) : undefined; +} + +export interface LangLink { + lang: string; + title: string; + url: string; + autonym?: string; +} + +export function parseLangLinks(json: unknown): LangLink[] { + const p = (json as { + query?: { pages?: { langlinks?: { lang: string; title: string; url?: string; autonym?: string }[] }[] }; + }) + .query?.pages?.[0]; + return (p?.langlinks ?? []).map((l) => ({ + lang: l.lang, + title: l.title, + url: l.url ?? pageUrl(l.lang, l.title), + autonym: l.autonym, + })); +} + +/** Known languages first, in the order of {@link LANGUAGES}, then the rest by code. */ +export function sortLangLinks(links: LangLink[], first: string[] = []): LangLink[] { + const order = [...first, ...Object.keys(LANGUAGES)]; + const rank = (l: LangLink) => { + const i = order.indexOf(l.lang); + return i === -1 ? order.length : i; + }; + return [...links].sort((a, b) => rank(a) - rank(b) || a.lang.localeCompare(b.lang)); +} + +interface RestSummary { + type?: string; + title: string; + titles?: { normalized?: string; display?: string }; + description?: string; + extract?: string; + thumbnail?: { source: string; width?: number; height?: number }; + originalimage?: { source: string; width?: number; height?: number }; + content_urls?: { desktop?: { page?: string } }; + timestamp?: string; + coordinates?: { lat: number; lon: number }; + lang?: string; +} + +/** A REST `page/summary` (also what `page/random/summary` and the feeds return). */ +export function parseSummary(lang: string, s: RestSummary): Page { + const title = s.titles?.normalized ?? s.title.replace(/_/g, " "); + return { + lang: s.lang ?? lang, + title, + description: s.description || undefined, + extract: (s.extract ?? "").trim(), + url: s.content_urls?.desktop?.page ?? pageUrl(lang, title), + image: s.thumbnail ? { url: s.thumbnail.source, width: s.thumbnail.width, height: s.thumbnail.height } : undefined, + edited: s.timestamp, + disambiguation: s.type === "disambiguation" || undefined, + coordinates: s.coordinates, + }; +} + +// --------------------------------------------------------------------------- +// On this day + +export type DayKind = "selected" | "events" | "births" | "deaths" | "holidays"; + +export interface DayEntry { + kind: DayKind; + text: string; + year?: number; + pages: Page[]; +} + +export function parseOnThisDay(lang: string, json: unknown): DayEntry[] { + const j = json as Partial<Record<DayKind, { text: string; year?: number; pages?: RestSummary[] }[]>>; + const kinds: DayKind[] = ["selected", "events", "births", "deaths", "holidays"]; + return kinds.flatMap((kind) => + (j[kind] ?? []).map((e) => ({ + kind, + text: e.text.trim(), + year: e.year, + pages: (e.pages ?? []).map((p) => parseSummary(lang, p)), + })) + ); +} + +// --------------------------------------------------------------------------- +// Text + +/** Removes `{\displaystyle …}` blocks (formulas in plain text extracts), braces balanced. */ +export function stripMath(text: string): string { + let out = ""; + let i = 0; + for (;;) { + const at = text.indexOf("{\\displaystyle", i); + if (at === -1) return out + text.slice(i); + out += text.slice(i, at); + let depth = 0; + let j = at; + for (; j < text.length; j++) { + if (text[j] === "{") depth++; + else if (text[j] === "}" && --depth === 0) break; + } + i = j + 1; + } +} + +/** Sections with nothing in them in plain text extracts (lists of links, references). */ +const EMPTY_SECTIONS = + /^(see also|references|notes|external links|further reading|bibliography|sources|voir aussi|références|notes et références|liens externes|bibliographie|articles connexes|einzelnachweise|weblinks|literatur|siehe auch|referencias|enlaces externos|véase también)$/i; + +/** + * A plain text extract with "== Heading ==" lines → markdown: headings, + * paragraphs, no formulas, no empty or link-only sections at the end. + */ +export function extractToMarkdown(text: string): string { + const lines = stripMath(text).replace(/\r/g, "").split("\n"); + const blocks: { level: number; heading?: string; body: string[] }[] = [{ level: 0, body: [] }]; + for (const line of lines) { + const h = line.match(/^(={2,6})\s*(.*?)\s*\1\s*$/); + if (h) blocks.push({ level: h[1].length, heading: h[2], body: [] }); + else if (line.trim()) blocks[blocks.length - 1].body.push(line.trim()); + } + // A heading is kept when it, or one of its subsections, has text. + const keep = blocks.map(() => false); + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.heading && EMPTY_SECTIONS.test(b.heading)) continue; + if (b.body.length) keep[i] = true; + for (let j = i + 1; j < blocks.length && blocks[j].level > b.level; j++) { + if (keep[j] && b.heading) keep[i] = true; + } + } + return blocks + .filter((_, i) => keep[i]) + .map((b) => { + const head = b.heading ? `${"#".repeat(Math.min(4, b.level))} ${b.heading}\n\n` : ""; + return head + b.body.join("\n\n"); + }) + .join("\n\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** The first `max` characters, cut after a sentence when possible. */ +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + const cut = text.slice(0, max); + const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf(".\n")); + return end > max * 0.3 ? cut.slice(0, end + 1) : `${cut.replace(/\s+\S*$/, "")}…`; +} + +/** The first paragraph only, for list subtitles and the copied summary. */ +export const firstParagraph = (text: string) => text.split(/\n+/).find((l) => l.trim())?.trim() ?? ""; + +/** At 230 words a minute. */ +export function readingTime(words: number): string { + const minutes = Math.max(1, Math.round(words / 230)); + return minutes >= 60 ? `${Math.floor(minutes / 60)} h ${minutes % 60} min` : `${minutes} min`; +} + +export const wordCount = (text: string) => (text.match(/[\p{L}\p{N}]+/gu) ?? []).length; + +export function formatDate(iso: string | undefined, locale = "en-GB"): string | undefined { + if (!iso) return undefined; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? undefined + : d.toLocaleDateString(locale, { day: "numeric", month: "short", year: "numeric" }); +} + +/** A Markdown link to the page, for notes. */ +export const markdownLink = (p: Page) => `[${p.title}](${p.url})`; diff --git a/extensions/wikipedia/lib_test.ts b/extensions/wikipedia/lib_test.ts new file mode 100644 index 0000000..103bae3 --- /dev/null +++ b/extensions/wikipedia/lib_test.ts @@ -0,0 +1,238 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { + absolute, + articleUrl, + extractToMarkdown, + firstParagraph, + isLanguage, + onThisDayUrl, + pageUrl, + parseArticle, + parseLangLinks, + parseOnThisDay, + parseSearch, + parseSummary, + preferredLanguage, + readingTime, + searchUrl, + sortLangLinks, + splitLanguage, + stripMath, + summaryUrl, + truncate, + wordCount, +} from "./lib.ts"; + +// Trimmed from https://fr.wikipedia.org/w/api.php?action=query&generator=search&gsrsearch=tour+eiffel&prop=extracts|pageimages|description|info… +const SEARCH = { + batchcomplete: true, + continue: { gsroffset: 2, continue: "gsroffset||" }, + query: { + pages: [ + { + pageid: 1359783, + ns: 0, + title: "Champ-de-Mars", + index: 2, + thumbnail: { + source: + "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Tour_Eiffel_Wikimedia_Commons.jpg/480px-Tour_Eiffel_Wikimedia_Commons.jpg", + width: 480, + height: 720, + }, + description: "jardin public à Paris", + descriptionsource: "central", + contentmodel: "wikitext", + pagelanguage: "fr", + touched: "2026-10-20T08:12:44Z", + length: 48213, + fullurl: "https://fr.wikipedia.org/wiki/Champ-de-Mars", + canonicalurl: "https://fr.wikipedia.org/wiki/Champ-de-Mars", + extract: "Le Champ-de-Mars est un vaste jardin public entièrement ouvert, situé à Paris.", + }, + { + pageid: 1359, + ns: 0, + title: "Tour Eiffel", + index: 1, + thumbnail: { + source: + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Tour_Eiffel_Wikimedia_Commons_%28cropped%29.jpg/320px-Tour_Eiffel.jpg", + width: 320, + height: 480, + }, + description: "tour de fer puddlé de 330 m de hauteur située à Paris", + touched: "2026-10-27T19:01:02Z", + length: 182044, + fullurl: "https://fr.wikipedia.org/wiki/Tour_Eiffel", + canonicalurl: "https://fr.wikipedia.org/wiki/Tour_Eiffel", + extract: + "La tour Eiffel est une tour de fer puddlé de 330 m de hauteur située à Paris.\nConstruite en deux ans par Gustave Eiffel et ses collaborateurs pour l'Exposition universelle de 1889.", + }, + { ns: 0, title: "Ghost", missing: true }, + ], + }, +}; + +const SUMMARY = { + type: "standard", + title: "Eiffel_Tower", + displaytitle: '<span class="mw-page-title-main">Eiffel Tower</span>', + titles: { canonical: "Eiffel_Tower", normalized: "Eiffel Tower", display: "Eiffel Tower" }, + pageid: 9232, + thumbnail: { + source: "https://upload.wikimedia.org/wikipedia/commons/thumb/8/85/Tour_Eiffel.jpg/330px-Tour_Eiffel.jpg", + width: 330, + height: 495, + }, + lang: "en", + dir: "ltr", + timestamp: "2026-10-25T10:05:12Z", + description: "Tower in Paris, France", + coordinates: { lat: 48.85822222, lon: 2.2945 }, + content_urls: { + desktop: { page: "https://en.wikipedia.org/wiki/Eiffel_Tower" }, + mobile: { page: "https://en.m.wikipedia.org/wiki/Eiffel_Tower" }, + }, + extract: "The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris, France.", +}; + +const ARTICLE_TEXT = `The Eiffel Tower is a wrought-iron lattice tower. +It is named after the engineer Gustave Eiffel. + +== History == + +=== Origin === +The design of the Eiffel Tower is attributed to Maurice Koechlin. +=== Construction === + +Work on the foundations started on 28 January 1887. + +== Design == +The tower is {\\displaystyle h=330{\\text{ m}}} tall. + +== See also == + +== References == + + +== External links == +`; + +Deno.test("languages", () => { + assertEquals(preferredLanguage("FR", "en_US.UTF-8"), "fr"); + assertEquals(preferredLanguage("", "de_DE.UTF-8"), "de"); + assertEquals(preferredLanguage(undefined, "C.UTF-8"), "en"); + assertEquals(preferredLanguage("not a code!", undefined), "en"); + assert(isLanguage("zh-yue") && isLanguage("simple") && !isLanguage("e")); + assertEquals(splitLanguage("fr: Tour Eiffel", "en"), { lang: "fr", text: "Tour Eiffel", override: true }); + assertEquals(splitLanguage("DE:Berlin", "en"), { lang: "de", text: "Berlin", override: true }); + assertEquals(splitLanguage("Star Wars: Episode IV", "en"), { + lang: "en", + text: "Star Wars: Episode IV", + override: false, + }); + assertEquals(splitLanguage("ja:", "en"), { lang: "ja", text: "", override: true }); +}); + +Deno.test("urls", () => { + assertEquals(pageUrl("en", "C++ (language)"), "https://en.wikipedia.org/wiki/C%2B%2B_(language)"); + assertEquals(summaryUrl("fr", "Tour Eiffel"), "https://fr.wikipedia.org/api/rest_v1/page/summary/Tour_Eiffel"); + const s = new URL(searchUrl("fr", "tour eiffel", 5)); + assertEquals(s.hostname, "fr.wikipedia.org"); + assertEquals(s.searchParams.get("gsrsearch"), "tour eiffel"); + assertEquals(s.searchParams.get("gsrlimit"), "5"); + assertEquals(s.searchParams.get("prop"), "extracts|pageimages|description|info"); + assertEquals(new URL(articleUrl("en", "Paris")).searchParams.get("exsectionformat"), "wiki"); + assertEquals( + onThisDayUrl("en", new Date(2026, 9, 3)), + "https://en.wikipedia.org/api/rest_v1/feed/onthisday/all/10/03", + ); + assertEquals(absolute("//upload.wikimedia.org/x.jpg"), "https://upload.wikimedia.org/x.jpg"); +}); + +Deno.test("search results in order, with their picture", () => { + const pages = parseSearch("fr", SEARCH); + assertEquals(pages.map((p) => p.title), ["Tour Eiffel", "Champ-de-Mars"]); + const [tour] = pages; + assertEquals(tour.url, "https://fr.wikipedia.org/wiki/Tour_Eiffel"); + assertEquals(tour.image?.width, 320); + assert(tour.image?.url.startsWith("https://upload.wikimedia.org/")); + assertEquals(tour.description, "tour de fer puddlé de 330 m de hauteur située à Paris"); + assertEquals(tour.length, 182044); + assertEquals( + firstParagraph(tour.extract), + "La tour Eiffel est une tour de fer puddlé de 330 m de hauteur située à Paris.", + ); + assertEquals(parseSearch("fr", { batchcomplete: true }), []); +}); + +Deno.test("summaries and articles", () => { + const p = parseSummary("en", SUMMARY); + assertEquals([p.title, p.lang, p.url], ["Eiffel Tower", "en", "https://en.wikipedia.org/wiki/Eiffel_Tower"]); + assertEquals(p.coordinates, { lat: 48.85822222, lon: 2.2945 }); + assertEquals(p.disambiguation, undefined); + assert(parseSummary("en", { ...SUMMARY, type: "disambiguation" }).disambiguation); + assertEquals(parseArticle("en", { query: { pages: [{ ns: 0, title: "Nope", missing: true }] } }), undefined); + const a = parseArticle("en", { + query: { pages: [{ pageid: 9232, title: "Eiffel Tower", extract: ARTICLE_TEXT, length: 120000 }] }, + })!; + assertEquals(a.url, "https://en.wikipedia.org/wiki/Eiffel_Tower"); +}); + +Deno.test("plain text extract to markdown", () => { + assertEquals( + extractToMarkdown(ARTICLE_TEXT), + [ + "The Eiffel Tower is a wrought-iron lattice tower.", + "It is named after the engineer Gustave Eiffel.", + "## History", + "### Origin", + "The design of the Eiffel Tower is attributed to Maurice Koechlin.", + "### Construction", + "Work on the foundations started on 28 January 1887.", + "## Design", + "The tower is tall.", + ].join("\n\n"), + ); + assertEquals(stripMath("a {\\displaystyle {\\frac {1}{2}}} b"), "a b"); + assertEquals(extractToMarkdown(""), ""); +}); + +Deno.test("language links, known languages first", () => { + const links = parseLangLinks({ + query: { + pages: [{ + title: "Eiffel Tower", + langlinks: [ + { lang: "eo", title: "Eiffel-Turo", autonym: "Esperanto", url: "https://eo.wikipedia.org/wiki/Eiffel-Turo" }, + { lang: "fr", title: "Tour Eiffel", autonym: "français", url: "https://fr.wikipedia.org/wiki/Tour_Eiffel" }, + { lang: "de", title: "Eiffelturm", autonym: "Deutsch" }, + ], + }], + }, + }); + assertEquals(sortLangLinks(links).map((l) => l.lang), ["fr", "de", "eo"]); + assertEquals(links[2].url, "https://de.wikipedia.org/wiki/Eiffelturm"); + assertEquals(parseLangLinks({ query: { pages: [{ title: "X" }] } }), []); +}); + +Deno.test("on this day feed", () => { + const entries = parseOnThisDay("en", { + selected: [{ text: "The Statue of Liberty is dedicated in New York Harbor.", year: 1886, pages: [SUMMARY] }], + births: [{ text: "Bill Gates, American businessman", year: 1955, pages: [] }], + holidays: [{ text: "Ochi Day (Greece)", pages: [] }], + }); + assertEquals(entries.map((e) => [e.kind, e.year]), [["selected", 1886], ["births", 1955], ["holidays", undefined]]); + assertEquals(entries[0].pages[0].title, "Eiffel Tower"); +}); + +Deno.test("text helpers", () => { + const long = "First sentence here. Second sentence is longer than the first one. Third."; + assertEquals(truncate(long, 50), "First sentence here."); + assertEquals(truncate("short", 50), "short"); + assertStringIncludes(truncate("a".repeat(30) + " " + "b".repeat(30), 40), "…"); + assertEquals(wordCount("L'été 2026, à Paris."), 5); + assertEquals(readingTime(460), "2 min"); + assertEquals(readingTime(30000), "2 h 10 min"); +}); diff --git a/extensions/wikipedia/ui.ts b/extensions/wikipedia/ui.ts new file mode 100644 index 0000000..4ad1ac1 --- /dev/null +++ b/extensions/wikipedia/ui.ts @@ -0,0 +1,259 @@ +// Items, actions and views shared by the commands. + +import { + type Action, + action, + close, + type Context, + copy, + detail, + type DetailSpec, + Icons, + type Item, + meta, + type Metadata, + open, + openExternal, + paste, + refresh, + storage, + submenu, +} from "@backslash/api"; +import { article, langLinks, NotFound } from "./api.ts"; +import { + extractToMarkdown, + firstParagraph, + formatDate, + languageName, + markdownLink, + type Page, + pageUrl, + preferredLanguage, + readingTime, + sortLangLinks, + truncate, + wordCount, +} from "./lib.ts"; + +const env = (k: string) => { + try { + return Deno.env.get(k); + } catch { + return undefined; + } +}; + +/** The language from the preferences, else the locale. */ +export const defaultLanguage = (ctx: Context) => + preferredLanguage( + (ctx.preferences as { language?: string }).language, + env("LC_ALL") || env("LC_MESSAGES") || env("LANG"), + ); + +// --------------------------------------------------------------------------- +// Recently viewed + +export interface Recent { + lang: string; + title: string; + description?: string; + image?: string; + url: string; + at: number; +} + +export const recents = async (): Promise<Recent[]> => ((await storage.get("recent")) ?? []) as unknown as Recent[]; + +export async function remember(p: Page): Promise<void> { + const entry: Recent = { + lang: p.lang, + title: p.title, + description: p.description, + image: p.image?.url, + url: p.url, + at: Date.now(), + }; + const kept = (await recents()).filter((r) => !(r.lang === p.lang && r.title === p.title)); + await storage.set("recent", [entry, ...kept].slice(0, 30) as any); +} + +export async function forget(r: Recent | undefined): Promise<void> { + if (!r) return storage.delete("recent"); + await storage.set("recent", (await recents()).filter((x) => !(x.lang === r.lang && x.title === r.title)) as any); +} + +// --------------------------------------------------------------------------- +// Pages + +const coords = (p: Page) => + p.coordinates + ? `${Math.abs(p.coordinates.lat).toFixed(4)}° ${p.coordinates.lat >= 0 ? "N" : "S"}, ${ + Math.abs(p.coordinates.lon).toFixed(4) + }° ${p.coordinates.lon >= 0 ? "E" : "W"}` + : undefined; + +export function pageMetadata(p: Page, extra: Metadata[] = []): Metadata[] { + const c = coords(p); + const edited = formatDate(p.edited); + return [ + ...(p.description ? [meta.text("Description", p.description, { icon: Icons.info })] : []), + meta.text("Language", `${languageName(p.lang)} (${p.lang})`, { icon: Icons.globe }), + ...(p.disambiguation ? [meta.tags("Type", [{ text: "Disambiguation", color: "orange" }])] : []), + ...(c + ? [ + meta.link( + "Coordinates", + c, + `https://www.openstreetmap.org/?mlat=${p.coordinates!.lat}&mlon=${p.coordinates!.lon}#map=12/${ + p.coordinates!.lat + }/${p.coordinates!.lon}`, + ), + ] + : []), + ...(p.length ? [meta.text("Size", `${Math.round(p.length / 1024)} kB of wikitext`, { icon: Icons.file })] : []), + ...(edited ? [meta.text("Last edited", edited, { icon: Icons.clock })] : []), + ...extra, + meta.separator(), + meta.link("Link", p.url.replace(/^https:\/\//, ""), p.url), + ]; +} + +/** Opens the page in the browser and keeps it in Recently Viewed. */ +const openPage = (p: Page) => + action("Open in Browser", async () => { + await remember(p).catch(() => {}); + await openExternal(p.url); + return close(); + }, { icon: Icons.globe, shortcut: "<Ctrl>o" }); + +const readHere = (p: Page) => + action("Read Here", () => articleView(p.lang, p.title), { icon: Icons.book, shortcut: "<Ctrl>r" }); + +/** + * Open (Enter: the browser, or the launcher with the "read here" preference), + * copy the link, the summary, a markdown link; open in another language. + */ +export function pageActions(p: Page, o: { readFirst?: boolean; readHere?: boolean } = {}): Action[] { + const summary = firstParagraph(p.extract); + const opening = o.readHere === false + ? [openPage(p)] + : o.readFirst + ? [readHere(p), openPage(p)] + : [openPage(p), readHere(p)]; + return [ + ...opening, + copy(p.url, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + ...(summary ? [copy(`${summary}\n\n${p.url}`, "Copy Summary", { section: "Copy", icon: Icons.text })] : []), + copy(markdownLink(p), "Copy Markdown Link", { section: "Copy", icon: Icons.link }), + paste(p.url, "Paste Link", { section: "Copy" }), + otherLanguages(p), + ]; +} + +const otherLanguages = (p: Page) => + submenu("Open in Another Language", async () => { + const links = sortLangLinks(await langLinks(p.lang, p.title)); + if (!links.length) return [action("Only in this language", () => undefined, { icon: Icons.info })]; + return links.map((l) => + action(`${l.autonym ?? languageName(l.lang)} · ${l.title}`, () => articleView(l.lang, l.title), { + icon: Icons.globe, + }) + ); + }, { icon: Icons.globe, shortcut: "<Ctrl>l", section: "Language" }); + +/** A search result, a random page or a recent one. */ +export function pageItem(p: Page, section?: string, readFirst = false): Item { + const intro = extractToMarkdown(truncate(p.extract, 1400)); + return { + id: `${p.lang}:${p.title}`, + title: p.title, + subtitle: p.description ?? truncate(firstParagraph(p.extract), 90), + icon: p.image?.url ?? { source: Icons.book, tint: p.disambiguation ? "orange" : "gray" }, + section, + accessories: p.disambiguation ? [{ tag: "Disambiguation", color: "orange" }] : [], + detail: { + image: p.image?.url, + markdown: [p.description ? `*${p.description}*` : "", intro || "No summary."].filter(Boolean).join("\n\n"), + metadata: pageMetadata(p), + }, + actions: pageActions(p, { readFirst }), + }; +} + +export function recentItem(r: Recent, readFirst = false): Item { + const page: Page = { + lang: r.lang, + title: r.title, + description: r.description, + extract: "", + url: r.url, + image: r.image ? { url: r.image } : undefined, + }; + return { + id: `recent:${r.lang}:${r.title}`, + title: r.title, + subtitle: r.description, + icon: r.image ?? { source: Icons.history, tint: "gray" }, + section: "Recently Viewed", + accessories: [{ tag: r.lang }], + detail: { + image: r.image, + metadata: [ + ...pageMetadata(page), + meta.text("Viewed", new Date(r.at).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" }), { + icon: Icons.history, + }), + ], + }, + actions: [ + ...pageActions(page, { readFirst }), + action("Remove from Recent", async () => { + await forget(r); + return refresh(); + }, { icon: Icons.trash, section: "Danger", destructive: true }), + ], + }; +} + +// --------------------------------------------------------------------------- +// The article, read in the launcher + +export async function articleView(lang: string, title: string): Promise<DetailSpec> { + let page: Page | undefined; + try { + page = await article(lang, title); + } catch (e) { + if (!(e instanceof NotFound)) throw e; + } + if (!page) { + return detail({ + title, + markdown: `# ${title}\n\nThis page doesn't exist on ${languageName(lang)} Wikipedia.`, + actions: [open(pageUrl(lang, title), "Open in Browser", { icon: Icons.globe })], + }); + } + const p = page; + await remember(p).catch(() => {}); + const body = extractToMarkdown(p.extract); + const words = wordCount(body); + return detail({ + title: p.title, + markdown: `# ${p.title}\n\n${p.description ? `*${p.description}*\n\n` : ""}${body || "No text."}`, + stats: [ + { label: "Words", value: words.toLocaleString("en-US") }, + { label: "Reading time", value: readingTime(words) }, + ], + metadata: pageMetadata(p), + status: `${languageName(p.lang)} Wikipedia`, + actions: [ + openPage(p), + copy(p.url, "Copy Link", { shortcut: "<Ctrl><Shift>c", section: "Copy" }), + copy(`# ${p.title}\n\n${body}\n\nSource: ${p.url}`, "Copy Article as Markdown", { + icon: Icons.text, + section: "Copy", + }), + copy(markdownLink(p), "Copy Markdown Link", { section: "Copy", icon: Icons.link }), + otherLanguages(p), + ], + }); +} diff --git a/extensions/window-manager/backslash.toml b/extensions/window-manager/backslash.toml new file mode 100644 index 0000000..75e09e4 --- /dev/null +++ b/extensions/window-manager/backslash.toml @@ -0,0 +1,183 @@ +[extension] +id = "window-manager" +title = "Window Manager" +description = "Snap, maximize, center, close and move the focused window between workspaces" +version = "1.0.0" +author = "Backslash" +icon = "bs-window" +license = "MIT" + +# One backend per desktop, the first that answers: hyprctl (Hyprland), swaymsg (Sway), +# gdbus to KWin's global shortcuts (KDE Plasma) or to the "Window Calls" Shell +# extension (GNOME on Wayland), wmctrl + xdotool + xrandr (X11). gsettings reads +# GNOME's workspace count. Extensions aren't told which window had the focus, +# so actions run from a short systemd-run --user job once the launcher has hidden. +[permissions] +run = ["systemd-run", "hyprctl", "swaymsg", "gdbus", "gsettings", "wmctrl", "xrandr"] +read = ["/etc/os-release"] +env = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "DBUS_SESSION_BUS_ADDRESS", +] + +[[commands]] +name = "windows" +title = "Window Manager" +description = "Every window action and your workspaces in one list" +icon = "bs-window" +keywords = ["window", "fenêtre", "tile", "tiling", "snap", "workspace", "espace de travail", "bureau virtuel", "layout", "disposition"] + +[[commands]] +name = "left-half" +title = "Left Half" +description = "Snap the window to the left half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile left", "snap left", "moitié gauche", "gauche", "fenêtre gauche"] + +[[commands]] +name = "right-half" +title = "Right Half" +description = "Snap the window to the right half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile right", "snap right", "moitié droite", "droite", "fenêtre droite"] + +[[commands]] +name = "top-half" +title = "Top Half" +description = "Snap the window to the top half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile top", "snap top", "moitié haut", "haut"] + +[[commands]] +name = "bottom-half" +title = "Bottom Half" +description = "Snap the window to the bottom half of the screen" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "tile bottom", "snap bottom", "moitié bas", "bas"] + +[[commands]] +name = "top-left" +title = "Top Left Quarter" +description = "Snap the window to the top left quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "haut gauche"] + +[[commands]] +name = "top-right" +title = "Top Right Quarter" +description = "Snap the window to the top right quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "haut droite"] + +[[commands]] +name = "bottom-left" +title = "Bottom Left Quarter" +description = "Snap the window to the bottom left quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "bas gauche"] + +[[commands]] +name = "bottom-right" +title = "Bottom Right Quarter" +description = "Snap the window to the bottom right quarter" +icon = "bs-window" +mode = "no-view" +keywords = ["window", "fenêtre", "quarter", "corner", "coin", "quart", "bas droite"] + +[[commands]] +name = "maximize" +title = "Maximize Window" +description = "Fill the screen with the window" +icon = "bs-monitor" +mode = "no-view" +keywords = ["window", "fenêtre", "maximise", "agrandir", "plein", "fill", "zoom"] + +[[commands]] +name = "center" +title = "Center Window" +description = "Move the window to the middle of the screen" +icon = "bs-dot" +mode = "no-view" +keywords = ["window", "fenêtre", "centrer", "middle", "milieu"] + +[[commands]] +name = "fullscreen" +title = "Toggle Fullscreen" +description = "Make the window fullscreen, or leave fullscreen" +icon = "bs-monitor" +mode = "no-view" +keywords = ["window", "fenêtre", "plein écran", "full screen", "f11"] + +[[commands]] +name = "toggle-floating" +title = "Toggle Floating" +description = "Float the window or put it back in the tiling layout (Hyprland, Sway)" +icon = "bs-layers" +mode = "no-view" +keywords = ["window", "fenêtre", "float", "flottant", "tiling"] + +[[commands]] +name = "minimize" +title = "Minimize Window" +description = "Hide the window to the taskbar" +icon = "bs-minus" +mode = "no-view" +keywords = ["window", "fenêtre", "minimise", "réduire", "hide", "cacher"] + +[[commands]] +name = "close-window" +title = "Close Window" +description = "Close the window you were in" +icon = "bs-x" +mode = "no-view" +keywords = ["window", "fenêtre", "fermer", "kill", "quit", "quitter fenêtre"] + +[[commands]] +name = "move-to-workspace" +title = "Move Window to Workspace" +description = "Send the window to workspace N and follow it" +icon = "bs-arrow-right" +mode = "no-view" +keywords = ["window", "fenêtre", "workspace", "espace de travail", "bureau", "déplacer", "move", "send"] +[[commands.arguments]] +name = "workspace" +placeholder = "Workspace" +type = "number" +min = 1 +max = 99 +required = true + +[[commands]] +name = "switch-workspace" +title = "Switch to Workspace" +description = "Show workspace N" +icon = "bs-layers" +mode = "no-view" +keywords = ["workspace", "espace de travail", "bureau", "changer", "switch", "go to", "aller"] +[[commands.arguments]] +name = "workspace" +placeholder = "Workspace" +type = "number" +min = 1 +max = 99 +required = true + +[[preferences]] +name = "delay" +title = "Delay before acting (milliseconds)" +description = "Time for the launcher to hide and the focus to return to your window; raise it if actions hit the wrong window" +type = "number" +default = 250 diff --git a/extensions/window-manager/commands/bottom-half.ts b/extensions/window-manager/commands/bottom-half.ts new file mode 100644 index 0000000..3a08092 --- /dev/null +++ b/extensions/window-manager/commands/bottom-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom")); diff --git a/extensions/window-manager/commands/bottom-left.ts b/extensions/window-manager/commands/bottom-left.ts new file mode 100644 index 0000000..bd6b1bf --- /dev/null +++ b/extensions/window-manager/commands/bottom-left.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom-left")); diff --git a/extensions/window-manager/commands/bottom-right.ts b/extensions/window-manager/commands/bottom-right.ts new file mode 100644 index 0000000..9b67c1e --- /dev/null +++ b/extensions/window-manager/commands/bottom-right.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("bottom-right")); diff --git a/extensions/window-manager/commands/center.ts b/extensions/window-manager/commands/center.ts new file mode 100644 index 0000000..694d814 --- /dev/null +++ b/extensions/window-manager/commands/center.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("center")); diff --git a/extensions/window-manager/commands/close-window.ts b/extensions/window-manager/commands/close-window.ts new file mode 100644 index 0000000..aa2b61b --- /dev/null +++ b/extensions/window-manager/commands/close-window.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("close")); diff --git a/extensions/window-manager/commands/fullscreen.ts b/extensions/window-manager/commands/fullscreen.ts new file mode 100644 index 0000000..42ea6f4 --- /dev/null +++ b/extensions/window-manager/commands/fullscreen.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("fullscreen")); diff --git a/extensions/window-manager/commands/left-half.ts b/extensions/window-manager/commands/left-half.ts new file mode 100644 index 0000000..d54018a --- /dev/null +++ b/extensions/window-manager/commands/left-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("left")); diff --git a/extensions/window-manager/commands/maximize.ts b/extensions/window-manager/commands/maximize.ts new file mode 100644 index 0000000..d9e7e94 --- /dev/null +++ b/extensions/window-manager/commands/maximize.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("maximize")); diff --git a/extensions/window-manager/commands/minimize.ts b/extensions/window-manager/commands/minimize.ts new file mode 100644 index 0000000..46b4ebe --- /dev/null +++ b/extensions/window-manager/commands/minimize.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("minimize")); diff --git a/extensions/window-manager/commands/move-to-workspace.ts b/extensions/window-manager/commands/move-to-workspace.ts new file mode 100644 index 0000000..c4f4f78 --- /dev/null +++ b/extensions/window-manager/commands/move-to-workspace.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { moveToWorkspace } from "../perform.ts"; + +export default run(moveToWorkspace); diff --git a/extensions/window-manager/commands/right-half.ts b/extensions/window-manager/commands/right-half.ts new file mode 100644 index 0000000..67d471d --- /dev/null +++ b/extensions/window-manager/commands/right-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("right")); diff --git a/extensions/window-manager/commands/switch-workspace.ts b/extensions/window-manager/commands/switch-workspace.ts new file mode 100644 index 0000000..81e7a68 --- /dev/null +++ b/extensions/window-manager/commands/switch-workspace.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { switchWorkspace } from "../perform.ts"; + +export default run(switchWorkspace); diff --git a/extensions/window-manager/commands/toggle-floating.ts b/extensions/window-manager/commands/toggle-floating.ts new file mode 100644 index 0000000..844930e --- /dev/null +++ b/extensions/window-manager/commands/toggle-floating.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("float")); diff --git a/extensions/window-manager/commands/top-half.ts b/extensions/window-manager/commands/top-half.ts new file mode 100644 index 0000000..a0b8cf0 --- /dev/null +++ b/extensions/window-manager/commands/top-half.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top")); diff --git a/extensions/window-manager/commands/top-left.ts b/extensions/window-manager/commands/top-left.ts new file mode 100644 index 0000000..8f4dc8e --- /dev/null +++ b/extensions/window-manager/commands/top-left.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top-left")); diff --git a/extensions/window-manager/commands/top-right.ts b/extensions/window-manager/commands/top-right.ts new file mode 100644 index 0000000..ed5a0e0 --- /dev/null +++ b/extensions/window-manager/commands/top-right.ts @@ -0,0 +1,4 @@ +import { run } from "@backslash/api"; +import { perform } from "../perform.ts"; + +export default run(perform("top-right")); diff --git a/extensions/window-manager/commands/windows.ts b/extensions/window-manager/commands/windows.ts new file mode 100644 index 0000000..b8322e9 --- /dev/null +++ b/extensions/window-manager/commands/windows.ts @@ -0,0 +1,249 @@ +import { + type Action, + action, + close, + type Context, + Icons, + type Item, + list, + meta, + open, + refresh, + submenu, + updateCommandMetadata, +} from "@backslash/api"; +import { + type ActionInfo, + ACTIONS, + type Backend, + BACKEND_NAMES, + type Family, + installMarkdown, + preview, + supports, + type Workspace, +} from "../lib.ts"; +import { delayOf } from "../perform.ts"; +import { act, detect, family, moveTo, type Session, switchTo, workspaces } from "../system.ts"; + +const WINDOW_CALLS = "https://extensions.gnome.org/extension/4724/window-calls/"; + +function workspaceActions(s: Session, ws: Workspace[], delay: number): Action[] { + const out: Action[] = [ + submenu( + "Move Window to Workspace", + () => + ws.map((w) => + action(label(w), async () => { + await moveTo(s, w.num, delay); + return close(); + }, { icon: Icons.arrowRight }) + ), + { icon: Icons.arrowRight, shortcut: "<Ctrl>m", section: "Workspaces" }, + ), + ]; + if (supports(s.backend!, "switch")) { + out.push(submenu( + "Switch to Workspace", + () => + ws.map((w) => + action(label(w), async () => { + await switchTo(s, w.num); + return close(); + }, { icon: w.active ? Icons.check : Icons.square }) + ), + { icon: Icons.layers, shortcut: "<Ctrl>w", section: "Workspaces" }, + )); + } + return out; +} + +const label = (w: Workspace) => + `${w.name && w.name !== String(w.num) ? `${w.num} · ${w.name}` : `Workspace ${w.num}`}${ + w.active ? " (current)" : "" + }`; + +function actionItem(info: ActionInfo, s: Session, ws: Workspace[], delay: number): Item { + const tint = info.section === "Snap" ? "blue" : info.id === "close" ? "red" : "purple"; + const pic = preview(info.id); + return { + id: info.id, + title: info.title, + icon: { source: info.icon, tint }, + section: info.section, + keywords: [...info.keywords, "window", "fenêtre"], + accessories: [{ text: info.command, color: "gray", tooltip: "Also a command of its own: give it a hotkey" }], + detail: { + markdown: `${pic ? `${pic}\n\n` : ""}${info.about}`, + metadata: [ + meta.text("Acts on", "The window you were in", { icon: Icons.window }), + meta.text("Backend", BACKEND_NAMES[s.backend!], { icon: Icons.monitor }), + meta.text("Command", info.command, { icon: Icons.keyboard }), + ...(s.backend === "hyprland" && (info.section === "Snap" || info.id === "center") + ? [meta.text("Note", "Makes a tiled window float", { icon: Icons.info })] + : []), + ], + }, + actions: [ + action(info.title, async () => { + await act(s, info.id, delay); + return close(); + }, { icon: info.icon, destructive: info.id === "close" }), + ...workspaceActions(s, ws, delay), + ], + }; +} + +function workspaceItem(w: Workspace, s: Session, delay: number): Item { + const canSwitch = supports(s.backend!, "switch"); + const actions: Action[] = []; + if (canSwitch) { + actions.push(action("Switch to Workspace", async () => { + await switchTo(s, w.num); + return close(); + }, { icon: Icons.layers })); + } + actions.push( + action("Move Window Here", async () => { + await moveTo(s, w.num, delay); + return close(); + }, { icon: Icons.arrowRight }), + ); + if (s.backend !== "gnome") { + actions.push(action("Send Window Here and Stay", async () => { + await moveTo(s, w.num, delay, false); + return close(); + }, { icon: Icons.upload })); + } + actions.push(action("Refresh", () => refresh(), { icon: Icons.refresh, shortcut: "<Ctrl>r", section: "View" })); + const windows = w.windows === undefined + ? undefined + : w.windows === 0 + ? "Empty" + : `${w.windows} window${w.windows === 1 ? "" : "s"}`; + return { + id: `ws:${w.num}`, + title: label(w).replace(" (current)", ""), + subtitle: windows, + icon: { source: Icons.layers, tint: w.active ? "green" : w.windows ? "blue" : "gray" }, + section: "Workspaces", + keywords: ["workspace", "espace de travail", "bureau", "desktop", String(w.num)], + accessories: [ + ...(w.output ? [{ text: w.output, icon: Icons.monitor }] : []), + ...(w.active ? [{ tag: "Current", color: "green" as const }] : []), + ], + detail: { + stats: [ + { label: "Workspace", value: w.num }, + ...(w.windows !== undefined ? [{ label: "Windows", value: w.windows }] : []), + ], + metadata: [ + meta.text("Name", w.name || String(w.num)), + meta.text("State", w.active ? "Current" : w.windows ? "In use" : "Empty", { + color: w.active ? "green" : "gray", + icon: w.active ? Icons.check : Icons.dottedCircle, + }), + ...(w.output ? [meta.text("Monitor", w.output, { icon: Icons.monitor })] : []), + meta.separator(), + meta.text("Enter", canSwitch ? "Switch to it" : "Move the window here", { icon: Icons.keyboard }), + ], + }, + actions, + }; +} + +function setupItems(s: Session, fam?: Family): Item[] { + const need = (b: Backend): Item => { + const what: Record<Backend, { title: string; md: string; actions: Action[] }> = { + hyprland: { + title: "hyprctl doesn't answer", + md: + "Backslash talks to Hyprland with `hyprctl`, which ships with it. Make sure the launcher runs inside your Hyprland session (`HYPRLAND_INSTANCE_SIGNATURE` set).", + actions: [], + }, + sway: { + title: "swaymsg doesn't answer", + md: + "Backslash talks to Sway with `swaymsg`, which ships with it. Make sure the launcher runs inside your Sway session (`SWAYSOCK` set).", + actions: [], + }, + kde: { + title: "KWin's shortcuts can't be reached", + md: installMarkdown( + "Backslash invokes KWin's own window shortcuts over D-Bus with **gdbus**.", + [fam === "arch" ? "glib2" : fam === "debian" ? "libglib2.0-bin" : "glib2"], + fam, + ), + actions: [], + }, + gnome: { + title: "Install the Window Calls extension", + md: + "GNOME on Wayland lets apps move windows only through a Shell extension. Install **Window Calls** (by ickyicky), log out and back in, then open this command again.", + actions: [open(WINDOW_CALLS, "Open Window Calls on extensions.gnome.org", { icon: Icons.globe })], + }, + x11: { + title: "Install wmctrl and xdotool", + md: installMarkdown( + "On X11, Backslash moves windows with **wmctrl** and finds their size with **xdotool**.", + ["wmctrl", "xdotool"], + fam, + ), + actions: [], + }, + }; + const w = what[b]; + return { + id: `setup:${b}`, + title: w.title, + subtitle: BACKEND_NAMES[b], + icon: { source: Icons.alert, tint: "orange" }, + section: "Setup", + detail: { + markdown: w.md, + metadata: [meta.tags("Supported", ["Hyprland", "Sway", "KDE Plasma", "GNOME + Window Calls", "X11"])], + }, + actions: [...w.actions, action("Check Again", () => refresh(), { icon: Icons.refresh })], + }; + }; + if (s.wanted.length) return s.wanted.map(need); + return [{ + id: "setup:none", + title: "No supported window manager", + subtitle: "Hyprland, Sway, KDE Plasma, GNOME or X11", + icon: { source: Icons.alert, tint: "orange" }, + detail: { + markdown: + "Backslash couldn't tell which desktop this is (`XDG_CURRENT_DESKTOP`, `WAYLAND_DISPLAY` and `DISPLAY` are empty or unknown).", + metadata: [meta.tags("Supported", ["Hyprland", "Sway", "KDE Plasma", "GNOME + Window Calls", "X11"])], + }, + actions: [action("Check Again", () => refresh(), { icon: Icons.refresh })], + }]; +} + +let summary = ""; + +export default (ctx: Context) => + list({ + placeholder: "Snap, maximize, move to a workspace…", + empty: { title: "Nothing matches", icon: Icons.window }, + status: () => summary, + async items() { + const delay = delayOf(ctx); + const s = await detect(); + if (!s.backend) { + summary = "No window manager found"; + return setupItems(s, await family()); + } + const ws = await workspaces(s.backend).catch(() => [] as Workspace[]); + const current = ws.find((w) => w.active); + summary = `${BACKEND_NAMES[s.backend]}${current ? ` · workspace ${current.num}` : ""}${ + ws.length ? ` · ${ws.length} workspaces` : "" + } · acts on the window you were in`; + updateCommandMetadata({ subtitle: current ? `Workspace ${current.name || current.num}` : null }).catch(() => {}); + return [ + ...ACTIONS.filter((a) => supports(s.backend!, a.id)).map((a) => actionItem(a, s, ws, delay)), + ...ws.map((w) => workspaceItem(w, s, delay)), + ]; + }, + }); diff --git a/extensions/window-manager/lib.ts b/extensions/window-manager/lib.ts new file mode 100644 index 0000000..6c27f52 --- /dev/null +++ b/extensions/window-manager/lib.ts @@ -0,0 +1,672 @@ +// Window geometry, compositor output parsing and the command lines that act +// on the focused window. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Session + +export type Backend = "hyprland" | "sway" | "kde" | "gnome" | "x11"; + +export type Env = Partial<Record<string, string>>; + +/** The environment variables we look at, and pass on to the background job. */ +export const SESSION_ENV = [ + "XDG_SESSION_TYPE", + "XDG_CURRENT_DESKTOP", + "WAYLAND_DISPLAY", + "DISPLAY", + "XAUTHORITY", + "HYPRLAND_INSTANCE_SIGNATURE", + "SWAYSOCK", + "DBUS_SESSION_BUS_ADDRESS", +] as const; + +/** Backends worth trying in this session, best first. Each is confirmed by talking to it. */ +export function candidates(env: Env): Backend[] { + const desktop = (env.XDG_CURRENT_DESKTOP ?? "").toLowerCase().split(":"); + const type = (env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const wayland = type === "wayland" || (!!env.WAYLAND_DISPLAY && type !== "x11"); + const out: Backend[] = []; + if (env.HYPRLAND_INSTANCE_SIGNATURE || desktop.includes("hyprland")) out.push("hyprland"); + if (env.SWAYSOCK || desktop.includes("sway")) out.push("sway"); + if (desktop.includes("kde")) out.push("kde"); + // On GNOME under X11, wmctrl does more than the Shell extension (fullscreen, workspaces). + const gnome = desktop.some((d) => ["gnome", "ubuntu", "unity", "pop"].includes(d)); + if (gnome && wayland) out.push("gnome"); + if (env.DISPLAY && !wayland) out.push("x11"); + if (gnome && !wayland) out.push("gnome"); + return out; +} + +export const BACKEND_NAMES: Record<Backend, string> = { + hyprland: "Hyprland", + sway: "Sway", + kde: "KDE Plasma (KWin)", + gnome: "GNOME (Window Calls)", + x11: "X11 (wmctrl)", +}; + +// --------------------------------------------------------------------------- +// Actions + +export type Tile = "left" | "right" | "top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type WindowAction = Tile | "maximize" | "center" | "fullscreen" | "float" | "minimize" | "close"; + +export interface ActionInfo { + id: WindowAction; + title: string; + /** The no-view command with the same effect, for the shortcut hint. */ + command: string; + icon: string; + section: "Snap" | "Window"; + about: string; + keywords: string[]; +} + +export const ACTIONS: ActionInfo[] = [ + { + id: "left", + title: "Left Half", + command: "left-half", + icon: "bs-window", + section: "Snap", + about: "Fills the left half of the screen.", + keywords: ["tile", "snap", "gauche", "moitié"], + }, + { + id: "right", + title: "Right Half", + command: "right-half", + icon: "bs-window", + section: "Snap", + about: "Fills the right half of the screen.", + keywords: ["tile", "snap", "droite", "moitié"], + }, + { + id: "top", + title: "Top Half", + command: "top-half", + icon: "bs-window", + section: "Snap", + about: "Fills the top half of the screen.", + keywords: ["tile", "snap", "haut", "moitié"], + }, + { + id: "bottom", + title: "Bottom Half", + command: "bottom-half", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom half of the screen.", + keywords: ["tile", "snap", "bas", "moitié"], + }, + { + id: "top-left", + title: "Top Left Quarter", + command: "top-left", + icon: "bs-window", + section: "Snap", + about: "Fills the top left quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "haut gauche"], + }, + { + id: "top-right", + title: "Top Right Quarter", + command: "top-right", + icon: "bs-window", + section: "Snap", + about: "Fills the top right quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "haut droite"], + }, + { + id: "bottom-left", + title: "Bottom Left Quarter", + command: "bottom-left", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom left quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "bas gauche"], + }, + { + id: "bottom-right", + title: "Bottom Right Quarter", + command: "bottom-right", + icon: "bs-window", + section: "Snap", + about: "Fills the bottom right quarter of the screen.", + keywords: ["tile", "corner", "coin", "quart", "bas droite"], + }, + { + id: "maximize", + title: "Maximize", + command: "maximize", + icon: "bs-monitor", + section: "Window", + about: "Fills the screen, panels and bars excepted.", + keywords: ["maximise", "agrandir", "plein", "fill"], + }, + { + id: "center", + title: "Center", + command: "center", + icon: "bs-dot", + section: "Window", + about: "Moves the window to the middle of the screen, keeping its size.", + keywords: ["centrer", "middle", "milieu"], + }, + { + id: "fullscreen", + title: "Toggle Fullscreen", + command: "fullscreen", + icon: "bs-monitor", + section: "Window", + about: "Covers the whole screen, bars included. Run it again to leave fullscreen.", + keywords: ["plein écran", "full screen", "kiosk"], + }, + { + id: "float", + title: "Toggle Floating", + command: "toggle-floating", + icon: "bs-layers", + section: "Window", + about: "Takes the window out of the tiling layout, or puts it back.", + keywords: ["float", "flottant", "tiling", "pseudo"], + }, + { + id: "minimize", + title: "Minimize", + command: "minimize", + icon: "bs-minus", + section: "Window", + about: "Hides the window to the taskbar or dock.", + keywords: ["minimise", "réduire", "hide", "cacher"], + }, + { + id: "close", + title: "Close Window", + command: "close-window", + icon: "bs-x", + section: "Window", + about: "Asks the window to close, as its close button does.", + keywords: ["fermer", "kill", "quit", "quitter"], + }, +]; + +export const actionInfo = (id: WindowAction) => ACTIONS.find((a) => a.id === id)!; + +/** What each backend can do. */ +export function supports(backend: Backend, a: WindowAction | "switch" | "move"): boolean { + if (a === "float") return backend === "hyprland" || backend === "sway"; + if (a === "minimize") return backend !== "hyprland" && backend !== "sway"; + if (a === "fullscreen") return backend !== "gnome"; + if (a === "switch") return backend !== "gnome"; + return true; +} + +// --------------------------------------------------------------------------- +// Geometry + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** The part of the work area a tile (or maximize) covers. */ +export function region(area: Rect, a: Tile | "maximize"): Rect { + const w1 = Math.floor(area.width / 2); + const h1 = Math.floor(area.height / 2); + const left = { x: area.x, width: w1 }; + const right = { x: area.x + w1, width: area.width - w1 }; + const full = { x: area.x, width: area.width }; + const top = { y: area.y, height: h1 }; + const bottom = { y: area.y + h1, height: area.height - h1 }; + const all = { y: area.y, height: area.height }; + switch (a) { + case "left": + return { ...left, ...all }; + case "right": + return { ...right, ...all }; + case "top": + return { ...full, ...top }; + case "bottom": + return { ...full, ...bottom }; + case "top-left": + return { ...left, ...top }; + case "top-right": + return { ...right, ...top }; + case "bottom-left": + return { ...left, ...bottom }; + case "bottom-right": + return { ...right, ...bottom }; + case "maximize": + return { ...area }; + } +} + +/** A window of this size in the middle of the area. */ +export function centered(area: Rect, width: number, height: number): Rect { + const w = Math.min(width, area.width); + const h = Math.min(height, area.height); + return { + x: area.x + Math.round((area.width - w) / 2), + y: area.y + Math.round((area.height - h) / 2), + width: w, + height: h, + }; +} + +export const isTile = (a: WindowAction): a is Tile => + ["left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right"].includes(a); + +/** A small picture of the screen with the covered part filled, for the detail panel. */ +export function preview(a: WindowAction): string { + const W = 24; + const H = 8; + const r = isTile(a) || a === "maximize" + ? region({ x: 0, y: 0, width: W, height: H }, a as Tile | "maximize") + : a === "center" + ? centered({ x: 0, y: 0, width: W, height: H }, 12, 4) + : a === "fullscreen" + ? { x: 0, y: 0, width: W, height: H } + : undefined; + if (!r) return ""; + const rows: string[] = []; + for (let y = 0; y < H; y++) { + let line = ""; + for (let x = 0; x < W; x++) { + line += x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height ? "█" : "░"; + } + rows.push(line); + } + return "```\n" + rows.join("\n") + "\n```"; +} + +// --------------------------------------------------------------------------- +// Workspaces + +export interface Workspace { + /** The number used to switch to it. */ + num: number; + name: string; + active: boolean; + /** Windows on it, when known. */ + windows?: number; + /** The output it is on, when known. */ + output?: string; +} + +/** Workspaces 1…n, filled with what the compositor reports; dynamic ones up to `min`. */ +export function numbered(known: Workspace[], min = 9): Workspace[] { + const byNum = new Map(known.filter((w) => w.num > 0).map((w) => [w.num, w])); + const max = Math.max(min, ...byNum.keys()); + const out: Workspace[] = []; + for (let n = 1; n <= max; n++) { + out.push(byNum.get(n) ?? { num: n, name: String(n), active: false, windows: 0 }); + } + return out; +} + +/** `hyprctl -j workspaces` + `hyprctl -j activeworkspace` (special workspaces left out). */ +export function hyprWorkspaces(workspacesJson: string, activeJson: string): Workspace[] { + const active = (JSON.parse(activeJson || "{}") as { id?: number }).id; + const list = JSON.parse(workspacesJson) as { id: number; name: string; windows?: number; monitor?: string }[]; + return list + .filter((w) => w.id > 0) + .map((w) => ({ num: w.id, name: w.name, active: w.id === active, windows: w.windows, output: w.monitor })) + .sort((a, b) => a.num - b.num); +} + +/** `swaymsg -t get_workspaces -r`. */ +export function swayWorkspaces(json: string): Workspace[] { + const list = JSON.parse(json) as { num: number; name: string; focused: boolean; output?: string }[]; + return list + .filter((w) => w.num > 0) + .map((w) => ({ num: w.num, name: w.name, active: w.focused, output: w.output })) + .sort((a, b) => a.num - b.num); +} + +/** `wmctrl -d`: desktops, and the work area of the current one. */ +export function wmctrlDesktops(text: string): { workspaces: Workspace[]; workarea?: Rect } { + const workspaces: Workspace[] = []; + let workarea: Rect | undefined; + for (const line of text.split("\n")) { + const m = line.match(/^(\d+)\s+([*-])\s+DG:\s*\S+\s+VP:\s*\S+\s+WA:\s*(-?\d+),(-?\d+)\s+(\d+)x(\d+)\s+(.*)$/); + if (!m) continue; + const active = m[2] === "*"; + workspaces.push({ num: Number(m[1]) + 1, name: m[7].trim() || `Workspace ${Number(m[1]) + 1}`, active }); + if (active) workarea = { x: +m[3], y: +m[4], width: +m[5], height: +m[6] }; + } + return { workspaces, workarea }; +} + +/** `gdbus … VirtualDesktopManager desktops` and `current` (KWin, Plasma 5.25+). */ +export function kwinDesktops(desktopsOut: string, currentOut: string): Workspace[] { + const current = currentOut.match(/'([^']*)'/)?.[1]; + const out: Workspace[] = []; + for (const m of desktopsOut.matchAll(/\('([^']*)',\s*(?:uint32\s+)?(\d+),\s*'((?:[^'\\]|\\.)*)'\)/g)) { + out.push({ num: Number(m[2]) + 1, name: m[3].replace(/\\(.)/g, "$1"), active: m[1] === current }); + } + return out.sort((a, b) => a.num - b.num); +} + +/** A number from gdbus output such as `(<uint32 4>,)` or `(4,)`. */ +export function gdbusNumber(out: string): number | undefined { + const m = out.match(/(?:u?int\d+\s+)?(-?\d+)/); + return m ? Number(m[1]) : undefined; +} + +// --------------------------------------------------------------------------- +// Hyprland + +interface HyprMonitor { + id: number; + name: string; + x: number; + y: number; + width: number; + height: number; + scale?: number; + transform?: number; + focused?: boolean; + reserved?: [number, number, number, number]; + activeWorkspace?: { id: number }; +} + +/** The usable area of a monitor from `hyprctl -j monitors` (logical pixels, bars excepted). */ +export function hyprArea(monitorsJson: string, monitorId?: number): Rect | undefined { + const monitors = JSON.parse(monitorsJson) as HyprMonitor[]; + const m = monitors.find((m) => monitorId !== undefined && m.id === monitorId) ?? monitors.find((m) => m.focused) ?? + monitors[0]; + if (!m) return undefined; + const scale = m.scale || 1; + const rotated = (m.transform ?? 0) % 2 === 1; + const w = Math.round((rotated ? m.height : m.width) / scale); + const h = Math.round((rotated ? m.width : m.height) / scale); + const [l, t, r, b] = m.reserved ?? [0, 0, 0, 0]; + return { x: m.x + l, y: m.y + t, width: w - l - r, height: h - t - b }; +} + +/** The monitor of the focused window from `hyprctl -j activewindow`, unless it is the launcher. */ +export function hyprActiveMonitor(json: string): number | undefined { + try { + const w = JSON.parse(json) as { monitor?: number; class?: string }; + if (typeof w.monitor !== "number" || /backslash/i.test(w.class ?? "")) return undefined; + return w.monitor; + } catch { + return undefined; + } +} + +/** `hyprctl --batch` argument for an action on the active window. */ +export function hyprBatch(a: WindowAction, area?: Rect): string { + const d = (s: string) => `dispatch ${s}`; + if (isTile(a)) { + if (!area) throw new Error("Unknown screen size"); + const r = region(area, a); + return [ + d("setfloating"), + d(`resizeactive exact ${r.width} ${r.height}`), + d(`moveactive exact ${r.x} ${r.y}`), + ].join(" ; "); + } + switch (a) { + case "maximize": + return d("fullscreen 1"); + case "fullscreen": + return d("fullscreen 0"); + case "center": + return [d("setfloating"), d("centerwindow 1")].join(" ; "); + case "float": + return d("togglefloating"); + case "close": + return d("killactive"); + default: + throw new Error(`${actionInfo(a).title} isn't available on Hyprland`); + } +} + +// --------------------------------------------------------------------------- +// Sway + +/** The focused workspace's usable rectangle from `swaymsg -t get_workspaces -r` (bars excepted). */ +export function swayArea(json: string): Rect | undefined { + const list = JSON.parse(json) as { focused: boolean; rect: Rect }[]; + return (list.find((w) => w.focused) ?? list[0])?.rect; +} + +/** A `swaymsg` command for an action on the focused window. */ +export function swayCommand(a: WindowAction, area?: Rect): string { + if (isTile(a) || a === "maximize") { + if (!area) throw new Error("Unknown screen size"); + const r = region(area, a); + return `floating enable, resize set width ${r.width} px height ${r.height} px, move absolute position ${r.x} px ${r.y} px`; + } + switch (a) { + case "fullscreen": + return "fullscreen toggle"; + case "center": + return "floating enable, move position center"; + case "float": + return "floating toggle"; + case "close": + return "kill"; + default: + throw new Error(`${actionInfo(a).title} isn't available on Sway`); + } +} + +// --------------------------------------------------------------------------- +// KDE Plasma: KWin's own global shortcuts, invoked over D-Bus + +export function kwinShortcut(a: WindowAction | { move: number } | { switch: number }): string { + if (typeof a === "object") return "move" in a ? `Window to Desktop ${a.move}` : `Switch to Desktop ${a.switch}`; + const names: Partial<Record<WindowAction, string>> = { + left: "Window Quick Tile Left", + right: "Window Quick Tile Right", + top: "Window Quick Tile Top", + bottom: "Window Quick Tile Bottom", + "top-left": "Window Quick Tile Top Left", + "top-right": "Window Quick Tile Top Right", + "bottom-left": "Window Quick Tile Bottom Left", + "bottom-right": "Window Quick Tile Bottom Right", + maximize: "Window Maximize", + center: "Window Move Center", + fullscreen: "Window Fullscreen", + minimize: "Window Minimize", + close: "Window Close", + }; + const name = names[a]; + if (!name) throw new Error(`${actionInfo(a).title} isn't available on KDE Plasma`); + return name; +} + +/** Shell line invoking a KWin shortcut, with gdbus or one of the qdbus spellings. */ +export function kwinShortcutLine(name: string): string { + const n = q(name); + return [ + `gdbus call --session --dest org.kde.kglobalaccel --object-path /component/kwin --method org.kde.kglobalaccel.Component.invokeShortcut ${n}`, + `qdbus6 org.kde.kglobalaccel /component/kwin invokeShortcut ${n}`, + `qdbus org.kde.kglobalaccel /component/kwin invokeShortcut ${n}`, + ].map((l) => `${l} >/dev/null 2>&1`).join(" || "); +} + +// --------------------------------------------------------------------------- +// X11: wmctrl + xdotool, computed in the background job once the window has the focus back + +export interface Monitor extends Rect { + name: string; +} + +/** `xrandr --listactivemonitors`: ` 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1`. */ +export function xrandrMonitors(text: string): Monitor[] { + const out: Monitor[] = []; + for (const m of text.matchAll(/^\s*\d+:\s+\S+\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)\s+(\S+)/gm)) { + out.push({ width: +m[1], height: +m[2], x: +m[3], y: +m[4], name: m[5] }); + } + return out; +} + +/** + * Shell lines that find the active window's monitor (the one under its + * center), keep the part inside the desktop's work area (panels excepted), + * then move it with wmctrl. Sets L T W H for the usable area, X Y WIDTH HEIGHT for the window. + */ +export function x11Lines(a: WindowAction, monitors: Monitor[], workarea?: Rect): string[] { + const ACTIVE = "wmctrl -r :ACTIVE:"; + const unmax = `${ACTIVE} -b remove,maximized_vert,maximized_horz,fullscreen`; + switch (a) { + case "maximize": + return [`${ACTIVE} -b remove,fullscreen`, `${ACTIVE} -b add,maximized_vert,maximized_horz`]; + case "fullscreen": + return [`${ACTIVE} -b toggle,fullscreen`]; + case "close": + return ["wmctrl -c :ACTIVE:"]; + case "minimize": + return ["xdotool getactivewindow windowminimize"]; + case "float": + throw new Error("Floating only means something in a tiling window manager"); + } + const first = monitors[0] ?? workarea ?? { x: 0, y: 0, width: 1920, height: 1080 }; + const wa = workarea ?? first; + const lines = [ + `eval "$(xdotool getactivewindow getwindowgeometry --shell 2>/dev/null)"`, + `cx=$(( \${X:-0} + \${WIDTH:-0} / 2 )); cy=$(( \${Y:-0} + \${HEIGHT:-0} / 2 ))`, + `MX=${first.x}; MY=${first.y}; MW=${first.width}; MH=${first.height}`, + ...monitors.slice(1).map((m) => + `if [ $cx -ge ${m.x} ] && [ $cx -lt ${m.x + m.width} ] && [ $cy -ge ${m.y} ] && [ $cy -lt ${ + m.y + m.height + } ]; then MX=${m.x}; MY=${m.y}; MW=${m.width}; MH=${m.height}; fi` + ), + // Panels: what the work area takes off this monitor. + `L=$(( MX > ${wa.x} ? MX : ${wa.x} )); T=$(( MY > ${wa.y} ? MY : ${wa.y} ))`, + `R=$(( MX + MW < ${wa.x + wa.width} ? MX + MW : ${wa.x + wa.width} )); B=$(( MY + MH < ${ + wa.y + wa.height + } ? MY + MH : ${wa.y + wa.height} ))`, + `W=$(( R - L )); H=$(( B - T ))`, + unmax, + ]; + if (a === "center") { + return [...lines, `${ACTIVE} -e 0,$(( L + (W - \${WIDTH:-W}) / 2 )),$(( T + (H - \${HEIGHT:-H}) / 2 )),-1,-1`]; + } + const geometry: Record<Tile, string> = { + left: "$L,$T,$(( W / 2 )),$H", + right: "$(( L + W / 2 )),$T,$(( W - W / 2 )),$H", + top: "$L,$T,$W,$(( H / 2 ))", + bottom: "$L,$(( T + H / 2 )),$W,$(( H - H / 2 ))", + "top-left": "$L,$T,$(( W / 2 )),$(( H / 2 ))", + "top-right": "$(( L + W / 2 )),$T,$(( W - W / 2 )),$(( H / 2 ))", + "bottom-left": "$L,$(( T + H / 2 )),$(( W / 2 )),$(( H - H / 2 ))", + "bottom-right": "$(( L + W / 2 )),$(( T + H / 2 )),$(( W - W / 2 )),$(( H - H / 2 ))", + }; + return [...lines, `${ACTIVE} -e 0,${geometry[a as Tile]}`]; +} + +// --------------------------------------------------------------------------- +// GNOME: the "Window Calls" Shell extension + +/** The string inside gdbus's `('…',)`, unescaped. */ +export function gvariantString(out: string): string { + const t = out.trim(); + const m = t.match(/^\((['"])([\s\S]*)\1,\)$/); + if (!m) throw new Error(`Unexpected gdbus output: ${t.slice(0, 80)}`); + return m[2].replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (_, e: string) => { + if (e[0] === "u" && e.length === 5) return String.fromCharCode(parseInt(e.slice(1), 16)); + if (e[0] === "x" && e.length === 3) return String.fromCharCode(parseInt(e.slice(1), 16)); + return ({ n: "\n", t: "\t", r: "\r" } as Record<string, string>)[e] ?? e; + }); +} + +interface GnomeWindow { + id: number; + wm_class?: string; + wm_class_instance?: string; + focus?: boolean; + window_type?: number; + in_current_workspace?: boolean; +} + +/** + * The window the user was in, from Window Calls' `List` (in stacking order, + * bottom to top): the focused one, or the topmost below the launcher. + */ +export function gnomeTarget(listJson: string): number | undefined { + const all = (JSON.parse(listJson) as GnomeWindow[]) + .filter((w) => (w.window_type ?? 0) === 0 && w.in_current_workspace !== false); + const others = all.filter((w) => !/backslash/i.test(`${w.wm_class ?? ""} ${w.wm_class_instance ?? ""}`)); + return (others.find((w) => w.focus) ?? others.at(-1))?.id; +} + +const isRect = (r: any): r is Rect => + r && typeof r === "object" && [r.x, r.y, r.width, r.height].every((n) => typeof n === "number") && r.width > 0; + +/** Work area and frame from Window Calls' `Details` (fields vary between versions). */ +export function gnomeDetails(json: string): { area?: Rect; frame?: Rect; maximized: boolean } { + const d = JSON.parse(json) as Record<string, any>; + const frame = { x: d.x, y: d.y, width: d.width, height: d.height }; + return { + area: [d.area, d.area_cust, d.work_area].find(isRect), + frame: isRect(frame) ? frame : undefined, + maximized: !!d.maximized, + }; +} + +/** A JSON rectangle from Window Calls' `GetFrameRect`. */ +export function parseRect(json: string): Rect | undefined { + const r = JSON.parse(json); + return isRect(r) ? r : undefined; +} + +// --------------------------------------------------------------------------- +// Background job + +export const q = (s: string) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; + +/** The shell script run once the launcher is hidden. */ +export function delayedScript(lines: string[], delayMs: number): string { + const s = Math.max(0, delayMs) / 1000; + return [...(s ? [`sleep ${s}`] : []), ...lines].join("\n"); +} + +/** `systemd-run` arguments to run a script in the background, outside the launcher. */ +export function systemdRunArgs(unit: string, script: string, env: Env = {}): string[] { + return [ + "--user", + "--quiet", + `--unit=${unit}`, + "--description=Backslash: window action", + "--collect", + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + "/bin/sh", + "-c", + script, + ]; +} + +// --------------------------------------------------------------------------- +// What to install + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record<Family, [string, string]> = { + arch: ["Arch", "sudo pacman -S"], + debian: ["Debian, Ubuntu", "sudo apt install"], + fedora: ["Fedora", "sudo dnf install"], + suse: ["openSUSE", "sudo zypper install"], +}; + +export function installMarkdown(what: string, packages: string[], family?: Family): string { + const families = family ? [family] : (Object.keys(MANAGERS) as Family[]); + const lines = families.map((f) => `**${MANAGERS[f][0]}**\n\n\`${MANAGERS[f][1]} ${packages.join(" ")}\``); + return `${what}\n\n${lines.join("\n\n")}`; +} diff --git a/extensions/window-manager/lib_test.ts b/extensions/window-manager/lib_test.ts new file mode 100644 index 0000000..2af2742 --- /dev/null +++ b/extensions/window-manager/lib_test.ts @@ -0,0 +1,243 @@ +import { assertEquals, assertStringIncludes, assertThrows } from "@std/assert"; +import { + candidates, + centered, + delayedScript, + gdbusNumber, + gnomeDetails, + gnomeTarget, + gvariantString, + hyprActiveMonitor, + hyprArea, + hyprBatch, + hyprWorkspaces, + kwinDesktops, + kwinShortcut, + kwinShortcutLine, + numbered, + osFamily, + preview, + region, + supports, + swayArea, + swayCommand, + swayWorkspaces, + systemdRunArgs, + wmctrlDesktops, + x11Lines, + xrandrMonitors, +} from "./lib.ts"; + +Deno.test("backends tried for each desktop", () => { + assertEquals(candidates({ HYPRLAND_INSTANCE_SIGNATURE: "abc", WAYLAND_DISPLAY: "wayland-1" }), ["hyprland"]); + assertEquals(candidates({ SWAYSOCK: "/run/user/1000/sway.sock", XDG_CURRENT_DESKTOP: "sway" }), ["sway"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "w" }), [ + "kde", + ]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "KDE", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["kde", "x11"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME", XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" }), [ + "gnome", + ]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "GNOME", XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }), ["x11", "gnome"]); + assertEquals(candidates({ XDG_CURRENT_DESKTOP: "XFCE", DISPLAY: ":0" }), ["x11"]); + assertEquals(candidates({}), []); +}); + +Deno.test("what each backend supports", () => { + assertEquals(supports("hyprland", "float"), true); + assertEquals(supports("kde", "float"), false); + assertEquals(supports("hyprland", "minimize"), false); + assertEquals(supports("gnome", "fullscreen"), false); + assertEquals(supports("gnome", "switch"), false); + assertEquals(supports("x11", "switch"), true); +}); + +const AREA = { x: 0, y: 32, width: 1921, height: 1048 }; + +Deno.test("halves and quarters cover the area with no gap", () => { + assertEquals(region(AREA, "left"), { x: 0, y: 32, width: 960, height: 1048 }); + assertEquals(region(AREA, "right"), { x: 960, y: 32, width: 961, height: 1048 }); + assertEquals(region(AREA, "top"), { x: 0, y: 32, width: 1921, height: 524 }); + assertEquals(region(AREA, "bottom"), { x: 0, y: 556, width: 1921, height: 524 }); + assertEquals(region(AREA, "bottom-right"), { x: 960, y: 556, width: 961, height: 524 }); + assertEquals(region(AREA, "maximize"), AREA); + assertEquals(centered(AREA, 800, 600), { x: 561, y: 256, width: 800, height: 600 }); + assertEquals(centered(AREA, 4000, 600).width, 1921); +}); + +Deno.test("preview draws the covered part", () => { + const p = preview("left"); + assertStringIncludes(p, "████████████░░░░░░░░░░░░"); + assertEquals(preview("close"), ""); +}); + +// Hyprland 0.45, a 4K laptop screen at 1.5 with a 30 px waybar, and an external monitor. +const HYPR_MONITORS = JSON.stringify([ + { + id: 0, + name: "eDP-1", + width: 2880, + height: 1800, + x: 0, + y: 0, + scale: 1.5, + transform: 0, + focused: false, + reserved: [0, 30, 0, 0], + activeWorkspace: { id: 1, name: "1" }, + }, + { + id: 1, + name: "DP-2", + width: 1080, + height: 1920, + x: 1920, + y: 0, + scale: 1, + transform: 1, + focused: true, + reserved: [0, 0, 0, 0], + activeWorkspace: { id: 4, name: "4" }, + }, +]); + +Deno.test("hyprland usable area, scaled and rotated", () => { + assertEquals(hyprArea(HYPR_MONITORS, 0), { x: 0, y: 30, width: 1920, height: 1170 }); + assertEquals(hyprArea(HYPR_MONITORS), { x: 1920, y: 0, width: 1920, height: 1080 }); + assertEquals(hyprActiveMonitor(JSON.stringify({ address: "0x5", monitor: 0, class: "firefox" })), 0); + assertEquals(hyprActiveMonitor(JSON.stringify({ monitor: 1, class: "app.backslash.Backslash" })), undefined); + assertEquals(hyprActiveMonitor("{}"), undefined); +}); + +Deno.test("hyprland dispatches", () => { + assertEquals( + hyprBatch("left", { x: 0, y: 30, width: 1920, height: 1170 }), + "dispatch setfloating ; dispatch resizeactive exact 960 1170 ; dispatch moveactive exact 0 30", + ); + assertEquals(hyprBatch("maximize"), "dispatch fullscreen 1"); + assertEquals(hyprBatch("fullscreen"), "dispatch fullscreen 0"); + assertEquals(hyprBatch("float"), "dispatch togglefloating"); + assertEquals(hyprBatch("close"), "dispatch killactive"); + assertThrows(() => hyprBatch("minimize")); +}); + +Deno.test("hyprland workspaces, special ones left out", () => { + const ws = hyprWorkspaces( + JSON.stringify([ + { id: 3, name: "3", monitor: "DP-2", windows: 2 }, + { id: 1, name: "web", monitor: "eDP-1", windows: 4 }, + { id: -98, name: "special:scratch", monitor: "eDP-1", windows: 1 }, + ]), + JSON.stringify({ id: 3, name: "3" }), + ); + assertEquals(ws.map((w) => [w.num, w.name, w.active, w.windows]), [[1, "web", false, 4], [3, "3", true, 2]]); + const all = numbered(ws, 5); + assertEquals(all.map((w) => w.num), [1, 2, 3, 4, 5]); + assertEquals(all[1], { num: 2, name: "2", active: false, windows: 0 }); +}); + +const SWAY_WS = JSON.stringify([ + { num: 1, name: "1", focused: false, output: "eDP-1", rect: { x: 0, y: 0, width: 1920, height: 1050 } }, + { num: 2, name: "2: code", focused: true, output: "eDP-1", rect: { x: 0, y: 30, width: 1920, height: 1050 } }, +]); + +Deno.test("sway workspaces and commands", () => { + assertEquals(swayWorkspaces(SWAY_WS).map((w) => [w.num, w.name, w.active]), [[1, "1", false], [2, "2: code", true]]); + assertEquals(swayArea(SWAY_WS), { x: 0, y: 30, width: 1920, height: 1050 }); + assertEquals( + swayCommand("right", swayArea(SWAY_WS)), + "floating enable, resize set width 960 px height 1050 px, move absolute position 960 px 30 px", + ); + assertEquals(swayCommand("center"), "floating enable, move position center"); + assertEquals(swayCommand("close"), "kill"); +}); + +Deno.test("kwin shortcuts and desktops", () => { + assertEquals(kwinShortcut("top-left"), "Window Quick Tile Top Left"); + assertEquals(kwinShortcut({ move: 3 }), "Window to Desktop 3"); + assertEquals(kwinShortcut({ switch: 2 }), "Switch to Desktop 2"); + assertThrows(() => kwinShortcut("float")); + const line = kwinShortcutLine("Window Maximize"); + assertStringIncludes(line, "invokeShortcut 'Window Maximize'"); + assertStringIncludes(line, " || qdbus6 "); + const desktops = kwinDesktops( + "(<[('0a1b-uuid', uint32 0, 'Desktop 1'), ('2c3d-uuid', 1, 'Mail \\'n\\' chat')]>,)", + "(<'2c3d-uuid'>,)", + ); + assertEquals(desktops, [ + { num: 1, name: "Desktop 1", active: false }, + { num: 2, name: "Mail 'n' chat", active: true }, + ]); + assertEquals(gdbusNumber("(<uint32 4>,)"), 4); +}); + +Deno.test("wmctrl desktops and xrandr monitors", () => { + const { workspaces, workarea } = wmctrlDesktops( + "0 - DG: 3840x1080 VP: N/A WA: 0,27 3840x1053 Workspace 1\n1 * DG: 3840x1080 VP: 0,0 WA: 0,27 3840x1053 Mail\n", + ); + assertEquals(workspaces.map((w) => [w.num, w.name, w.active]), [[1, "Workspace 1", false], [2, "Mail", true]]); + assertEquals(workarea, { x: 0, y: 27, width: 3840, height: 1053 }); + const monitors = xrandrMonitors( + "Monitors: 2\n 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1\n 1: +HDMI-1 1920/527x1080/296+1920+0 HDMI-1\n", + ); + assertEquals(monitors, [ + { width: 1920, height: 1080, x: 0, y: 0, name: "eDP-1" }, + { width: 1920, height: 1080, x: 1920, y: 0, name: "HDMI-1" }, + ]); +}); + +Deno.test("x11 script snaps on the window's monitor, inside the work area", async () => { + const monitors = [ + { width: 1920, height: 1080, x: 0, y: 0, name: "eDP-1" }, + { width: 1920, height: 1080, x: 1920, y: 0, name: "HDMI-1" }, + ]; + const lines = x11Lines("right", monitors, { x: 0, y: 27, width: 3840, height: 1053 }); + assertEquals(lines.at(-1), "wmctrl -r :ACTIVE: -e 0,$(( L + W / 2 )),$T,$(( W - W / 2 )),$H"); + // Run the arithmetic with a window on the second monitor, wmctrl replaced by echo. + const script = ["X=2000; Y=100; WIDTH=800; HEIGHT=600", ...lines.slice(1)].join("\n").replaceAll("wmctrl", "echo"); + const out = await new Deno.Command("sh", { args: ["-c", script], stdout: "piped" }).output(); + assertEquals(new TextDecoder().decode(out.stdout).trim().split("\n").at(-1), "-r :ACTIVE: -e 0,2880,27,960,1053"); + const center = x11Lines("center", monitors, { x: 0, y: 27, width: 3840, height: 1053 }); + const out2 = await new Deno.Command("sh", { + args: ["-c", ["X=10; Y=50; WIDTH=800; HEIGHT=600", ...center.slice(1)].join("\n").replaceAll("wmctrl", "echo")], + stdout: "piped", + }).output(); + assertEquals(new TextDecoder().decode(out2.stdout).trim().split("\n").at(-1), "-r :ACTIVE: -e 0,560,253,-1,-1"); + assertEquals(x11Lines("close", monitors), ["wmctrl -c :ACTIVE:"]); + assertThrows(() => x11Lines("float", monitors)); +}); + +Deno.test("gnome: gdbus strings and the window under the launcher", () => { + const list = gvariantString( + `('[{"wm_class":"firefox","id":11,"focus":false,"window_type":0,"in_current_workspace":true},{"wm_class":"Code","id":12,"focus":false,"window_type":0,"in_current_workspace":false},{"wm_class":"org.gnome.Nautilus","id":13,"focus":false,"window_type":0,"in_current_workspace":true},{"wm_class":"backslash","id":14,"focus":true,"window_type":0,"in_current_workspace":true}]',)`, + ); + assertEquals(gnomeTarget(list), 13); + assertEquals(gnomeTarget(JSON.stringify([{ id: 2, wm_class: "kitty", focus: true }, { id: 3 }])), 2); + assertEquals(gnomeTarget("[]"), undefined); + assertEquals(gvariantString(`('it\\'s',)`), "it's"); + const d = gnomeDetails( + JSON.stringify({ + x: 10, + y: 40, + width: 800, + height: 600, + maximized: 0, + area: { x: 0, y: 32, width: 1920, height: 1048 }, + }), + ); + assertEquals(d.area, { x: 0, y: 32, width: 1920, height: 1048 }); + assertEquals(d.frame, { x: 10, y: 40, width: 800, height: 600 }); + assertEquals(gnomeDetails(JSON.stringify({ x: 1, y: 1, width: 5, height: 5, area: {} })).area, undefined); +}); + +Deno.test("background job", () => { + assertEquals(delayedScript(["a", "b"], 250), "sleep 0.25\na\nb"); + assertEquals(delayedScript(["a"], 0), "a"); + const args = systemdRunArgs("u", "echo", { DISPLAY: ":0", SWAYSOCK: undefined }); + assertEquals(args.slice(-4), ["--setenv=DISPLAY=:0", "/bin/sh", "-c", "echo"]); +}); + +Deno.test("os family", () => { + assertEquals(osFamily("ID=ubuntu\nID_LIKE=debian\n"), "debian"); + assertEquals(osFamily("ID=fedora\n"), "fedora"); +}); diff --git a/extensions/window-manager/perform.ts b/extensions/window-manager/perform.ts new file mode 100644 index 0000000..c9cb20e --- /dev/null +++ b/extensions/window-manager/perform.ts @@ -0,0 +1,45 @@ +// The no-view commands: one action on the window the user was in. + +import type { Context } from "@backslash/api"; +import { actionInfo, BACKEND_NAMES, supports, type WindowAction } from "./lib.ts"; +import { act, detect, moveTo, switchTo } from "./system.ts"; + +export interface Prefs { + delay?: number; +} + +export const delayOf = (ctx: Context) => { + const d = Number((ctx.preferences as Prefs).delay); + return Number.isFinite(d) && d >= 0 ? Math.min(d, 2000) : 250; +}; + +async function session() { + const s = await detect(); + if (!s.backend) { + throw new Error("No supported window manager: open the Window Manager command to see what to install"); + } + return s; +} + +/** Closes the launcher with no message, so the focus goes back to the window right away. */ +export const perform = (a: WindowAction) => async (ctx: Context): Promise<void> => { + const s = await session(); + if (!supports(s.backend!, a)) { + throw new Error(`${actionInfo(a).title} isn't available on ${BACKEND_NAMES[s.backend!]}`); + } + await act(s, a, delayOf(ctx)); +}; + +const workspaceArg = (ctx: Context) => { + const n = Math.trunc(Number((ctx.args as { workspace?: unknown }).workspace)); + if (!Number.isFinite(n) || n < 1) throw new Error("Give a workspace number, from 1"); + return n; +}; + +export const moveToWorkspace = async (ctx: Context): Promise<void> => { + await moveTo(await session(), workspaceArg(ctx), delayOf(ctx)); +}; + +export const switchWorkspace = async (ctx: Context): Promise<void> => { + await switchTo(await session(), workspaceArg(ctx)); +}; diff --git a/extensions/window-manager/system.ts b/extensions/window-manager/system.ts new file mode 100644 index 0000000..23f963d --- /dev/null +++ b/extensions/window-manager/system.ts @@ -0,0 +1,357 @@ +// Talking to the compositor: detection, workspaces, and running an action on +// the window the user was in before opening the launcher. +// +// Extensions aren't told which window that was. So every action runs from a +// short-lived background job (systemd-run) a moment after the launcher has +// hidden, when the focus is back on that window. GNOME is the exception: the +// Window Calls extension lists windows in stacking order, so the target is +// the topmost window under the launcher and is acted on right away. + +import { exec } from "@backslash/api"; +import { + type Backend, + candidates, + centered, + delayedScript, + type Env, + gdbusNumber, + gnomeDetails, + gnomeTarget, + gvariantString, + hyprActiveMonitor, + hyprArea, + hyprBatch, + hyprWorkspaces, + isTile, + kwinDesktops, + kwinShortcut, + kwinShortcutLine, + numbered, + osFamily, + parseRect, + q, + type Rect, + region, + SESSION_ENV, + swayArea, + swayCommand, + swayWorkspaces, + systemdRunArgs, + type WindowAction, + wmctrlDesktops, + type Workspace, + x11Lines, + xrandrMonitors, +} from "./lib.ts"; + +const env = (key: string): string | undefined => { + try { + return Deno.env.get(key) || undefined; + } catch { + return undefined; + } +}; + +export const sessionEnv = (): Env => Object.fromEntries(SESSION_ENV.map((k) => [k, env(k)])); + +const missing = (e: unknown) => + e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable; + +const GNOME_DEST = ["--dest", "org.gnome.Shell", "--object-path", "/org/gnome/Shell/Extensions/Windows"]; + +function gnomeCall(method: string, ...args: (string | number)[]): Promise<string> { + return exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + ...GNOME_DEST, + "--method", + `org.gnome.Shell.Extensions.Windows.${method}`, + ...args.map(String), + ]); +} + +function kwinProperty(name: string): Promise<string> { + return exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.kde.KWin", + "--object-path", + "/VirtualDesktopManager", + "--method", + "org.freedesktop.DBus.Properties.Get", + "org.kde.KWin.VirtualDesktopManager", + name, + ]); +} + +/** Whether the backend answers here. */ +async function works(b: Backend): Promise<boolean> { + try { + switch (b) { + case "hyprland": + await exec("hyprctl", ["-j", "version"]); + return true; + case "sway": + await exec("swaymsg", ["-t", "get_version"]); + return true; + case "kde": + await exec("gdbus", [ + "call", + "--session", + "--timeout", + "2", + "--dest", + "org.kde.kglobalaccel", + "--object-path", + "/component/kwin", + "--method", + "org.kde.kglobalaccel.Component.shortcutNames", + ]); + return true; + case "gnome": + await gnomeCall("List"); + return true; + case "x11": + await exec("wmctrl", ["-m"]); + return true; + } + } catch (e) { + if (missing(e)) return false; + return false; + } +} + +export interface Session { + backend?: Backend; + /** Backends this desktop would use, for the empty state. */ + wanted: Backend[]; + env: Env; +} + +export async function detect(): Promise<Session> { + const e = sessionEnv(); + const wanted = candidates(e); + for (const b of wanted) { + if (await works(b)) return { backend: b, wanted, env: e }; + } + return { wanted, env: e }; +} + +export async function family() { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +// --------------------------------------------------------------------------- +// Workspaces + +export async function workspaces(b: Backend): Promise<Workspace[]> { + switch (b) { + case "hyprland": { + const [list, active] = await Promise.all([ + exec("hyprctl", ["-j", "workspaces"]), + exec("hyprctl", ["-j", "activeworkspace"]), + ]); + return numbered(hyprWorkspaces(list, active)); + } + case "sway": + return numbered(swayWorkspaces(await exec("swaymsg", ["-t", "get_workspaces", "-r"]))); + case "kde": { + const [desktops, current] = await Promise.all([kwinProperty("desktops"), kwinProperty("current")]); + const found = kwinDesktops(desktops, current); + if (found.length) return found; + const count = gdbusNumber(await kwinProperty("count")) ?? 4; + return numbered([], count); + } + case "gnome": { + // Window Calls doesn't tell which workspace is active; GNOME's settings say how many there are. + const dynamic = await exec("gsettings", ["get", "org.gnome.mutter", "dynamic-workspaces"]).catch(() => "true"); + const count = dynamic === "false" + ? Number( + await exec("gsettings", ["get", "org.gnome.desktop.wm.preferences", "num-workspaces"]) + .catch(() => "4"), + ) || 4 + : 4; + return numbered([], count); + } + case "x11": { + const { workspaces } = wmctrlDesktops(await exec("wmctrl", ["-d"])); + return workspaces; + } + } +} + +// --------------------------------------------------------------------------- +// Running actions + +/** Starts the script in the background once the launcher has hidden. */ +async function detach(s: Session, lines: string[], delayMs: number) { + const script = delayedScript(lines, delayMs); + try { + await exec("systemd-run", systemdRunArgs(`backslash-window-${Date.now()}`, script, s.env)); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new Error("systemd-run is needed to act on your window once the launcher has hidden"); + } + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: window actions run in the background with systemd-run --user"); + } + throw e; + } +} + +async function hyprAreaNow(): Promise<Rect | undefined> { + const [monitors, active] = await Promise.all([ + exec("hyprctl", ["-j", "monitors"]), + exec("hyprctl", ["-j", "activewindow"]).catch(() => ""), + ]); + return hyprArea(monitors, hyprActiveMonitor(active)); +} + +async function x11Setup() { + const [desktops, monitors] = await Promise.all([ + exec("wmctrl", ["-d"]), + exec("xrandr", ["--listactivemonitors"]).catch(() => ""), + ]); + return { workarea: wmctrlDesktops(desktops).workarea, monitors: xrandrMonitors(monitors) }; +} + +/** The shell lines doing `a` on the focused window, prepared before the launcher hides. */ +async function linesFor(s: Session, a: WindowAction): Promise<string[]> { + switch (s.backend) { + case "hyprland": { + const area = isTile(a) ? await hyprAreaNow() : undefined; + return [`hyprctl --batch ${q(hyprBatch(a, area))} >/dev/null`]; + } + case "sway": { + const area = isTile(a) || a === "maximize" + ? swayArea(await exec("swaymsg", ["-t", "get_workspaces", "-r"])) + : undefined; + return [`swaymsg ${q(swayCommand(a, area))} >/dev/null`]; + } + case "kde": + return [kwinShortcutLine(kwinShortcut(a))]; + case "x11": { + const { monitors, workarea } = await x11Setup(); + return x11Lines(a, monitors, workarea); + } + default: + throw new Error("No window manager to talk to"); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function gnomeWindow(): Promise<number> { + const id = gnomeTarget(gvariantString(await gnomeCall("List"))); + if (id === undefined) throw new Error("No window to act on"); + return id; +} + +/** GNOME: the target is known, so act at once through Window Calls. */ +async function gnomeAct(a: WindowAction) { + const id = await gnomeWindow(); + switch (a) { + case "maximize": + await gnomeCall("Maximize", id); + return; + case "minimize": + await gnomeCall("Minimize", id); + return; + case "close": + await gnomeCall("Close", id); + return; + case "fullscreen": + throw new Error("GNOME's Window Calls extension can't make a window fullscreen: press F11 in the app"); + case "float": + throw new Error("Floating only means something in a tiling window manager"); + } + const details = gnomeDetails(gvariantString(await gnomeCall("Details", id))); + let area = details.area; + if (!area) { + // Older Window Calls: a maximized window's frame is the work area. + await gnomeCall("Maximize", id); + await sleep(200); + area = parseRect(gvariantString(await gnomeCall("GetFrameRect", id))); + } + if (!area) throw new Error("GNOME didn't tell the size of the screen: update the Window Calls extension"); + if (details.maximized || !details.area) await gnomeCall("Unmaximize", id); + const r = a === "center" + ? centered( + area, + details.frame?.width ?? Math.round(area.width * 0.6), + details.frame?.height ?? Math.round(area.height * 0.6), + ) + : region(area, a); + await gnomeCall("MoveResize", id, r.x, r.y, r.width, r.height); +} + +/** Does `a` to the window the user was in. */ +export async function act(s: Session, a: WindowAction, delayMs: number): Promise<void> { + if (!s.backend) throw new Error("No supported window manager found"); + if (s.backend === "gnome") return await gnomeAct(a); + await detach(s, await linesFor(s, a), delayMs); +} + +/** Moves the window to workspace `n` and follows it. */ +export async function moveTo(s: Session, n: number, delayMs: number, follow = true): Promise<void> { + switch (s.backend) { + case "hyprland": + return await detach(s, [ + `hyprctl dispatch ${follow ? "movetoworkspace" : "movetoworkspacesilent"} ${n} >/dev/null`, + ], delayMs); + case "sway": + return await detach( + s, + [`swaymsg ${ + q(`move container to workspace number ${n}${follow ? `, workspace number ${n}` : ""}`) + } >/dev/null`], + delayMs, + ); + case "kde": + return await detach( + s, + [ + kwinShortcutLine(kwinShortcut({ move: n })), + ...(follow ? [kwinShortcutLine(kwinShortcut({ switch: n }))] : []), + ], + delayMs, + ); + case "x11": + return await detach(s, [`wmctrl -r :ACTIVE: -t ${n - 1}`, ...(follow ? [`wmctrl -s ${n - 1}`] : [])], delayMs); + case "gnome": { + const id = await gnomeWindow(); + await gnomeCall("MoveToWorkspace", id, n - 1); + if (follow) await gnomeCall("Activate", id); + return; + } + default: + throw new Error("No supported window manager found"); + } +} + +/** Shows workspace `n`. Nothing depends on the focused window, so it runs right away. */ +export async function switchTo(s: Session, n: number): Promise<void> { + switch (s.backend) { + case "hyprland": + await exec("hyprctl", ["dispatch", "workspace", String(n)]); + return; + case "sway": + await exec("swaymsg", [`workspace number ${n}`]); + return; + case "kde": + return await detach(s, [kwinShortcutLine(kwinShortcut({ switch: n }))], 0); + case "x11": + await exec("wmctrl", ["-s", String(n - 1)]); + return; + case "gnome": + throw new Error("GNOME doesn't let apps switch workspaces on Wayland: use Super+Page Up/Down"); + default: + throw new Error("No supported window manager found"); + } +} diff --git a/extensions/youtube/api.ts b/extensions/youtube/api.ts new file mode 100644 index 0000000..7375c2f --- /dev/null +++ b/extensions/youtube/api.ts @@ -0,0 +1,184 @@ +// The YouTube Data API v3 with the user's API key. Searches cost 100 of the +// 10,000 daily quota units, so answers are cached. + +import { Icons, type Item, open, storage } from "@backslash/api"; +import { + apiError, + type ApiVideo, + fromApi, + type PlaylistItem, + searchIds, + type SearchResult, + uploadsPlaylist, + type Video, +} from "./lib.ts"; + +const API = "https://www.googleapis.com/youtube/v3"; +export const KEY_URL = "https://console.cloud.google.com/apis/library/youtube.googleapis.com"; +export const CREDENTIALS_URL = "https://console.cloud.google.com/apis/credentials"; + +type Json = Parameters<typeof storage.set>[1]; + +export class AuthError extends Error {} +export class QuotaError extends Error {} + +export async function load<T>(key: string): Promise<T | undefined> { + return await storage.get(key).catch(() => undefined) as T | undefined; +} + +export async function save(key: string, value: unknown): Promise<void> { + await storage.set(key, value as Json).catch(() => {}); +} + +export interface Account { + key: string; + region: string; +} + +export function apiKey(preferences: Record<string, unknown>): string { + const key = String(preferences.apiKey ?? "").trim(); + if (!key) throw new AuthError("Add your YouTube Data API key"); + return key; +} + +async function yt<T>(a: Account, path: string, params: Record<string, string>, signal?: AbortSignal): Promise<T> { + const q = new URLSearchParams({ ...params, key: a.key }); + let res: Response; + try { + res = await fetch(`${API}/${path}?${q}`, { signal, headers: { Accept: "application/json" } }); + } catch (e) { + if ((e as Error).name === "AbortError") throw e; + throw new Error(`YouTube is unreachable: ${(e as Error).message}`); + } + const body = await res.json().catch(() => undefined); + if (!res.ok) { + const { message, auth, quota } = apiError(res.status, body); + throw auth ? new AuthError(message) : quota ? new QuotaError(message) : new Error(message); + } + return body as T; +} + +const memory = new Map<string, { at: number; data: unknown }>(); + +/** An answer kept `ttl` seconds in memory and storage (quota is precious). */ +async function cached<T>(id: string, ttl: number, fetcher: () => Promise<T>): Promise<T> { + const hit = (memory.get(id) as { at: number; data: T } | undefined) ?? await load<{ at: number; data: T }>(id); + if (hit && Date.now() - hit.at < ttl * 1000) return hit.data; + const data = await fetcher(); + const entry = { at: Date.now(), data }; + memory.set(id, entry); + await save(id, entry); + return data; +} + +/** Full details (duration, views…) of videos, in the given order. 1 quota unit. */ +export async function videos(a: Account, ids: string[], signal?: AbortSignal): Promise<Video[]> { + if (!ids.length) return []; + const r = await yt<{ items?: ApiVideo[] }>(a, "videos", { + part: "snippet,contentDetails,statistics", + id: ids.slice(0, 50).join(","), + maxResults: "50", + }, signal); + const byId = new Map((r.items ?? []).map((v) => [v.id, fromApi(v)])); + return ids.map((id) => byId.get(id)).filter((v): v is Video => !!v); +} + +export function search(a: Account, query: string, signal?: AbortSignal): Promise<Video[]> { + const q = query.trim(); + return cached(`search:${a.region}:${q.toLowerCase()}`, 3600, async () => { + const r = await yt<{ items?: SearchResult[] }>(a, "search", { + part: "snippet", + type: "video", + maxResults: "25", + q, + regionCode: a.region, + safeSearch: "moderate", + }, signal); + return videos(a, searchIds(r.items ?? []), signal); + }); +} + +export function trending(a: Account, signal?: AbortSignal): Promise<Video[]> { + return cached(`trending:${a.region}`, 1800, async () => { + const r = await yt<{ items?: ApiVideo[] }>(a, "videos", { + part: "snippet,contentDetails,statistics", + chart: "mostPopular", + regionCode: a.region, + maxResults: "25", + }, signal); + return (r.items ?? []).map(fromApi); + }); +} + +/** A channel's latest uploads. 2 quota units. */ +export function channelVideos(a: Account, channelId: string, signal?: AbortSignal): Promise<Video[]> { + return cached(`channel:${channelId}`, 1800, async () => { + const r = await yt<{ items?: PlaylistItem[] }>(a, "playlistItems", { + part: "contentDetails", + playlistId: uploadsPlaylist(channelId), + maxResults: "30", + }, signal); + const ids = (r.items ?? []).map((i) => i.contentDetails?.videoId).filter((id): id is string => !!id); + return videos(a, ids, signal); + }); +} + +// --------------------------------------------------------------------------- +// Setup + +export function setupItems(error: AuthError): Item[] { + return [{ + id: "setup", + title: error.message, + subtitle: "Free: a Google Cloud API key with the YouTube Data API v3", + icon: { source: Icons.key, tint: "orange" }, + detail: { + markdown: [ + "## Connect YouTube", + "", + "Searching YouTube reliably needs Google's **YouTube Data API v3** and your own free API key.", + "Keyless front ends (Invidious, Piped) break or get blocked too often to depend on.", + "", + "1. In the Google Cloud console, create a project (any name).", + "2. Open **YouTube Data API v3** (Enter opens it) and click **Enable**.", + "3. In **APIs & Services → Credentials**, click **Create credentials → API key**.", + " Restricting the key to the YouTube Data API v3 is a good idea.", + "4. Paste it in **Settings → Extensions → YouTube → Configure**.", + "", + "The free quota is 10,000 units a day: a search costs 100, trending and channels 1 or 2.", + "Results are cached for an hour to make it last.", + ].join("\n"), + }, + actions: [ + open(KEY_URL, "Enable the YouTube Data API", { icon: Icons.key }), + open(CREDENTIALS_URL, "Create an API Key", { icon: Icons.key }), + ], + }]; +} + +export async function guarded(load: () => Promise<Item[]>): Promise<Item[]> { + try { + return await load(); + } catch (e) { + if (e instanceof AuthError) return setupItems(e); + if (e instanceof QuotaError) { + return [{ + id: "quota", + title: e.message, + subtitle: "It resets at midnight Pacific time", + icon: { source: Icons.hourglass, tint: "orange" }, + detail: { + markdown: [ + "## Out of quota for today", + "", + "Each API key gets 10,000 units a day; a search costs 100 of them. Searches made today", + "are still cached for an hour. The quota resets at midnight Pacific time, or you can ask", + "Google for more in the Cloud console (Enter opens it).", + ].join("\n"), + }, + actions: [open("https://console.cloud.google.com/apis/api/youtube.googleapis.com/quotas", "Open Quotas")], + }]; + } + throw e; + } +} diff --git a/extensions/youtube/backslash.toml b/extensions/youtube/backslash.toml new file mode 100644 index 0000000..b557f81 --- /dev/null +++ b/extensions/youtube/backslash.toml @@ -0,0 +1,45 @@ +[extension] +id = "youtube" +title = "YouTube" +description = "Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels" +version = "1.0.0" +author = "Backslash" +icon = "bs-video" +license = "MIT" + +# The YouTube Data API v3 (www.googleapis.com) with the user's own API key, +# and i.ytimg.com where thumbnails are served. +# Videos play in mpv (with yt-dlp) as a transient systemd user unit, so they +# keep playing once the launcher is hidden; mpv and yt-dlp are also run once to +# check they are installed. The display variables let mpv open its window from +# that unit; /etc/os-release picks the install command to suggest. +[permissions] +net = ["www.googleapis.com", "i.ytimg.com"] +run = ["systemd-run", "systemctl", "mpv", "yt-dlp"] +read = ["/etc/os-release"] +env = ["WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"] + +[[commands]] +name = "search" +title = "Search YouTube" +description = "Trending videos, or type to search; open, play in mpv, copy the link, browse the channel" +icon = "bs-video" +keywords = ["youtube", "yt", "video", "videos", "watch", "music", "channel", "vidéo", "vidéos", "regarder", "chaîne"] + +[[preferences]] +name = "apiKey" +title = "YouTube Data API key" +description = "Google Cloud console → enable YouTube Data API v3 → Credentials → Create API key" +type = "secret" + +[[preferences]] +name = "region" +title = "Region" +description = "Two-letter country code for trending videos and results: US, GB, FR, DE…" +default = "US" + +[[preferences]] +name = "enter" +title = "Enter" +description = "What Enter does on a video: browser (open on youtube.com) or mpv (play in mpv)" +default = "browser" diff --git a/extensions/youtube/commands/search.ts b/extensions/youtube/commands/search.ts new file mode 100644 index 0000000..3498556 --- /dev/null +++ b/extensions/youtube/commands/search.ts @@ -0,0 +1,33 @@ +import { type Context, Icons, type Item, list } from "@backslash/api"; +import { type Account, apiKey, guarded, search, trending, videos } from "../api.ts"; +import { normalizeRegion, videoIdFrom } from "../lib.ts"; +import { nowPlaying } from "../player.ts"; +import { nowPlayingItem, type Options, videoItem } from "../views.ts"; + +export default (ctx: Context) => { + const region = normalizeRegion(ctx.preferences.region); + const enter = String(ctx.preferences.enter ?? "").trim().toLowerCase() === "mpv" ? "mpv" : "browser"; + return list({ + placeholder: "Search YouTube, or paste a video link…", + empty: { title: "No video found", description: "Try other words", icon: Icons.video }, + status: (items) => { + if (items[0]?.id === "setup") return "Not connected"; + if (items[0]?.id === "quota") return "Quota used up"; + const n = items.filter((i) => i.id !== "now-playing").length; + return `${n} video${n === 1 ? "" : "s"} · ${region} · YouTube Data API`; + }, + search: (query, { signal }) => + guarded(async (): Promise<Item[]> => { + const account: Account = { key: apiKey(ctx.preferences), region }; + const current = await nowPlaying(); + const o: Options = { account, enter, playing: current?.video.id }; + const pasted = videoIdFrom(query); + if (pasted) return (await videos(account, [pasted], signal)).map((v) => videoItem(v, o)); + if (query.trim()) return (await search(account, query, signal)).map((v) => videoItem(v, o)); + return [ + ...(current ? [nowPlayingItem(current)] : []), + ...(await trending(account, signal)).map((v) => videoItem(v, { ...o, section: `Trending in ${region}` })), + ]; + }), + }); +}; diff --git a/extensions/youtube/lib.ts b/extensions/youtube/lib.ts new file mode 100644 index 0000000..b573396 --- /dev/null +++ b/extensions/youtube/lib.ts @@ -0,0 +1,279 @@ +// YouTube Data API v3 answers, mpv command lines and formatting. Pure, tested in lib_test.ts. + +// --------------------------------------------------------------------------- +// Shapes + +interface Thumbs { + default?: { url: string }; + medium?: { url: string }; + high?: { url: string }; + standard?: { url: string }; + maxres?: { url: string }; +} + +/** An item of `videos.list?part=snippet,contentDetails,statistics`. */ +export interface ApiVideo { + id: string; + snippet?: { + publishedAt?: string; + channelId?: string; + title?: string; + description?: string; + thumbnails?: Thumbs; + channelTitle?: string; + tags?: string[]; + liveBroadcastContent?: "none" | "live" | "upcoming" | string; + defaultAudioLanguage?: string; + }; + contentDetails?: { duration?: string; definition?: string; caption?: string }; + statistics?: { viewCount?: string; likeCount?: string; commentCount?: string }; + liveStreamingDetails?: { concurrentViewers?: string; scheduledStartTime?: string }; +} + +/** An item of `search.list?type=video`. */ +export interface SearchResult { + id: { kind?: string; videoId?: string }; + snippet?: ApiVideo["snippet"]; +} + +/** An item of `playlistItems.list` (a channel's uploads). */ +export interface PlaylistItem { + contentDetails?: { videoId?: string; videoPublishedAt?: string }; +} + +export interface Video { + id: string; + title: string; + channel: string; + channelId?: string; + published?: string; + description?: string; + /** Seconds; undefined for live streams and premieres. */ + seconds?: number; + views?: number; + likes?: number; + comments?: number; + live?: "live" | "upcoming"; + hd?: boolean; + captions?: boolean; + tags: string[]; +} + +// --------------------------------------------------------------------------- +// Mapping + +const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; + +/** search.list escapes titles (`Rock & Roll`, `It's`); videos.list doesn't. */ +export function decode(s: string): string { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +/** ISO 8601 `PT1H2M3S` → 3723 seconds; `P1DT2H` too; `P0D` (live) → undefined. */ +export function parseDuration(iso: string | undefined): number | undefined { + const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso ?? ""); + if (!m) return undefined; + const [d, h, min, s] = m.slice(1).map((x) => Number(x ?? 0)); + const total = d * 86400 + h * 3600 + min * 60 + s; + return total || undefined; +} + +const num = (s: string | undefined) => (s === undefined || s === "" ? undefined : Number(s)); + +export function fromApi(v: ApiVideo): Video { + const sn = v.snippet ?? {}; + const live = sn.liveBroadcastContent === "live" || sn.liveBroadcastContent === "upcoming" + ? sn.liveBroadcastContent + : undefined; + return { + id: v.id, + title: decode(sn.title ?? v.id), + channel: decode(sn.channelTitle ?? ""), + channelId: sn.channelId, + published: sn.publishedAt, + description: sn.description || undefined, + seconds: live ? undefined : parseDuration(v.contentDetails?.duration), + views: num(v.statistics?.viewCount), + likes: num(v.statistics?.likeCount), + comments: num(v.statistics?.commentCount), + live, + hd: v.contentDetails?.definition === "hd", + captions: v.contentDetails?.caption === "true", + tags: sn.tags ?? [], + }; +} + +/** Video ids of search results, in order, without channels or playlists. */ +export const searchIds = ( + items: SearchResult[], +) => [...new Set(items.map((i) => i.id?.videoId).filter((id): id is string => !!id))]; + +/** A channel's uploads playlist: `UCxxxx` → `UUxxxx`. */ +export const uploadsPlaylist = (channelId: string) => channelId.replace(/^UC/, "UU"); + +// --------------------------------------------------------------------------- +// URLs + +export const watchUrl = (id: string) => `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`; +export const shortUrl = (id: string) => `https://youtu.be/${encodeURIComponent(id)}`; +export const channelUrl = (id: string) => `https://www.youtube.com/channel/${encodeURIComponent(id)}`; +export const thumbnail = (id: string, size: "mqdefault" | "hqdefault" | "sddefault") => + `https://i.ytimg.com/vi/${encodeURIComponent(id)}/${size}.jpg`; + +/** A video id from what the user pasted: a watch, youtu.be, shorts or embed URL. */ +export function videoIdFrom(text: string): string | undefined { + const t = text.trim(); + const m = /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/|live\/)|youtu\.be\/)([\w-]{11})/.exec(t); + return m?.[1]; +} + +// --------------------------------------------------------------------------- +// mpv + +export function mpvArgs(url: string, o: { audioOnly?: boolean; title?: string } = {}): string[] { + return [ + "mpv", + "--no-terminal", + ...(o.audioOnly + ? ["--no-video", "--ytdl-format=bestaudio/best"] + : ["--force-window=immediate", "--ytdl-format=bestvideo[height<=?1080]+bestaudio/best"]), + ...(o.title ? [`--title=${o.title}`] : []), + url, + ]; +} + +/** `systemd-run` arguments for a background unit, with the display variables mpv needs. */ +export function unitArgs(unit: string, description: string, env: Record<string, string | undefined>, argv: string[]) { + return [ + "--user", + "--quiet", + "--collect", + `--unit=${unit}`, + `--description=${description}`, + ...Object.entries(env).filter(([, v]) => v).map(([k, v]) => `--setenv=${k}=${v}`), + ...argv, + ]; +} + +// --------------------------------------------------------------------------- +// Formatting + +/** 3723 → "1:02:03"; 75 → "1:15". */ +export function clock(seconds: number | undefined): string | undefined { + if (seconds === undefined) return undefined; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const ss = String(s).padStart(2, "0"); + return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`; +} + +export const compact = (n: number) => + new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); + +export function views(n: number | undefined): string | undefined { + if (n === undefined) return undefined; + return `${compact(n)} view${n === 1 ? "" : "s"}`; +} + +/** "3 hours ago", "2 weeks ago", "5 years ago", as YouTube says it. */ +export function ago(iso: string | undefined, now = Date.now()): string | undefined { + if (!iso) return undefined; + const t = Date.parse(iso); + if (Number.isNaN(t)) return undefined; + const s = Math.max(0, (now - t) / 1000); + const units: [number, string][] = [ + [365 * 86400, "year"], + [30 * 86400, "month"], + [7 * 86400, "week"], + [86400, "day"], + [3600, "hour"], + [60, "minute"], + ]; + for (const [size, name] of units) { + const n = Math.floor(s / size); + if (n >= 1) return `${n} ${name}${n === 1 ? "" : "s"} ago`; + } + return "just now"; +} + +export const longDate = (iso: string | undefined) => + iso + ? new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }) + : ""; + +/** The first lines of a description, without its link lists and hashtags walls. */ +export function excerpt(text: string | undefined, max = 600): string | undefined { + if (!text) return undefined; + const kept: string[] = []; + let length = 0; + let cut = false; + for (const line of text.replace(/\r/g, "").split("\n").map((l) => l.trim())) { + if (/^(#\w+\s*)+$/.test(line)) continue; + if (length + line.length > max) { + if (!kept.length) kept.push(line.slice(0, max)); + cut = true; + break; + } + kept.push(line); + length += line.length + 1; + } + const out = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim(); + return out ? out + (cut ? "…" : "") : undefined; +} + +/** `"us"` → `"US"`; anything else than two letters → `"US"`. */ +export function normalizeRegion(input: unknown): string { + const r = String(input ?? "").trim().toUpperCase(); + return /^[A-Z]{2}$/.test(r) ? r : "US"; +} + +/** The Data API's error body → a sentence and whether it is the key's fault. */ +export function apiError( + status: number, + body: { error?: { message?: string; errors?: { reason?: string }[] } } | undefined, +): { message: string; auth: boolean; quota?: boolean } { + const reason = body?.error?.errors?.[0]?.reason ?? ""; + if (reason === "keyInvalid" || (status === 400 && /API key not valid/i.test(body?.error?.message ?? ""))) { + return { message: "Google rejected the API key", auth: true }; + } + if (reason === "accessNotConfigured" || /has not been used|is disabled/i.test(body?.error?.message ?? "")) { + return { message: "Enable the YouTube Data API v3 for this key's Google Cloud project", auth: true }; + } + if (reason === "quotaExceeded" || reason === "dailyLimitExceeded") { + return { message: "Today's YouTube API quota is used up", auth: false, quota: true }; + } + if (reason === "ipRefererBlocked" || reason === "forbidden") { + return { message: "This API key doesn't allow requests from here (check its restrictions)", auth: true }; + } + return { message: `YouTube answered ${status}${body?.error?.message ? `: ${body.error.message}` : ""}`, auth: false }; +} + +export type Family = "arch" | "debian" | "fedora" | "suse"; + +export function osFamily(osRelease: string): Family | undefined { + const field = (k: string) => osRelease.match(new RegExp(`^${k}="?([^"\\n]*)"?`, "m"))?.[1]?.toLowerCase() ?? ""; + const ids = `${field("ID")} ${field("ID_LIKE")}`.split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros", "cachyos"].includes(i))) return "arch"; + if (ids.some((i) => ["debian", "ubuntu", "linuxmint", "pop"].includes(i))) return "debian"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "fedora"; + if (ids.some((i) => i.startsWith("opensuse") || i === "suse")) return "suse"; + return undefined; +} + +const MANAGERS: Record<Family, string> = { + arch: "sudo pacman -S", + debian: "sudo apt install", + fedora: "sudo dnf install", + suse: "sudo zypper install", +}; + +export function installCommand(family: Family | undefined, packages: string[]): string { + return family ? `${MANAGERS[family]} ${packages.join(" ")}` : `Install ${packages.join(" and ")}`; +} diff --git a/extensions/youtube/lib_test.ts b/extensions/youtube/lib_test.ts new file mode 100644 index 0000000..0162ddf --- /dev/null +++ b/extensions/youtube/lib_test.ts @@ -0,0 +1,172 @@ +import { assertEquals } from "@std/assert"; +import { + ago, + apiError, + type ApiVideo, + clock, + decode, + excerpt, + fromApi, + installCommand, + mpvArgs, + normalizeRegion, + parseDuration, + searchIds, + thumbnail, + unitArgs, + uploadsPlaylist, + videoIdFrom, + views, +} from "./lib.ts"; +import { videoItem } from "./views.ts"; + +// Shaped like `videos.list?part=snippet,contentDetails,statistics`. +const rick: ApiVideo = { + id: "dQw4w9WgXcQ", + snippet: { + publishedAt: "2009-10-25T06:57:33Z", + channelId: "UCuAXFkgsw1L7xaCfnd5JJOw", + title: "Rick Astley - Never Gonna Give You Up (Official Music Video)", + description: "The official video for “Never Gonna Give You Up”\n\n#RickAstley #NeverGonnaGiveYouUp", + channelTitle: "Rick Astley", + tags: ["rick astley", "never gonna give you up"], + liveBroadcastContent: "none", + }, + contentDetails: { duration: "PT3M33S", definition: "hd", caption: "true" }, + statistics: { viewCount: "1612345678", likeCount: "18000000", commentCount: "2400000" }, +}; +const live: ApiVideo = { + id: "jfKfPfyJRdk", + snippet: { title: "lofi hip hop radio", channelTitle: "Lofi Girl", liveBroadcastContent: "live" }, + contentDetails: { duration: "P0D" }, + statistics: { viewCount: "90000000" }, +}; + +const account = { key: "k", region: "US" }; + +Deno.test("durations", () => { + assertEquals(parseDuration("PT3M33S"), 213); + assertEquals(parseDuration("PT1H2M3S"), 3723); + assertEquals(parseDuration("PT45S"), 45); + assertEquals(parseDuration("P1DT2H"), 93600); + assertEquals(parseDuration("P0D"), undefined); + assertEquals(parseDuration("garbage"), undefined); + assertEquals(clock(213), "3:33"); + assertEquals(clock(3723), "1:02:03"); + assertEquals(clock(19), "0:19"); + assertEquals(clock(undefined), undefined); +}); + +Deno.test("videos", () => { + const v = fromApi(rick); + assertEquals(v.seconds, 213); + assertEquals(v.views, 1612345678); + assertEquals(v.hd, true); + assertEquals(v.captions, true); + const l = fromApi(live); + assertEquals(l.live, "live"); + assertEquals(l.seconds, undefined); + assertEquals(l.likes, undefined); + assertEquals(decode("Rock & Roll 'n' "Soul""), `Rock & Roll 'n' "Soul"`); + assertEquals( + searchIds([ + { id: { kind: "youtube#video", videoId: "a" } }, + { id: { kind: "youtube#channel" } }, + { id: { videoId: "a" } }, + { id: { videoId: "b" } }, + ]), + ["a", "b"], + ); + assertEquals(uploadsPlaylist("UCuAXFkgsw1L7xaCfnd5JJOw"), "UUuAXFkgsw1L7xaCfnd5JJOw"); +}); + +Deno.test("links", () => { + assertEquals(videoIdFrom("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("https://www.youtube.com/watch?list=PL1&v=dQw4w9WgXcQ"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ"); + assertEquals(videoIdFrom("https://youtube.com/shorts/abcdefghijk"), "abcdefghijk"); + assertEquals(videoIdFrom("never gonna give you up"), undefined); + assertEquals(thumbnail("dQw4w9WgXcQ", "hqdefault"), "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"); +}); + +Deno.test("formatting", () => { + const now = Date.parse("2026-10-28T10:00:00Z"); + assertEquals(ago("2026-10-28T07:00:00Z", now), "3 hours ago"); + assertEquals(ago("2026-10-27T09:00:00Z", now), "1 day ago"); + assertEquals(ago("2026-10-10T09:00:00Z", now), "2 weeks ago"); + assertEquals(ago("2009-10-25T06:57:33Z", now), "17 years ago"); + assertEquals(ago(undefined, now), undefined); + assertEquals(views(1), "1 view"); + assertEquals(views(1612345678), "1.6B views"); + assertEquals(excerpt("Line one\n#tag #other\n\n\n\nLine two"), "Line one\n\nLine two"); + assertEquals(excerpt("a".repeat(700))?.length, 601); + assertEquals(normalizeRegion("fr"), "FR"); + assertEquals(normalizeRegion("France"), "US"); +}); + +Deno.test("API errors", () => { + assertEquals( + apiError(400, { + error: { message: "API key not valid. Please pass a valid API key.", errors: [{ reason: "badRequest" }] }, + }), + { message: "Google rejected the API key", auth: true }, + ); + assertEquals(apiError(403, { error: { errors: [{ reason: "quotaExceeded" }] } }).quota, true); + assertEquals( + apiError(403, { + error: { + message: "YouTube Data API v3 has not been used in project 1", + errors: [{ reason: "accessNotConfigured" }], + }, + }) + .auth, + true, + ); + assertEquals(apiError(500, undefined), { message: "YouTube answered 500", auth: false }); +}); + +Deno.test("mpv", () => { + assertEquals(mpvArgs("https://www.youtube.com/watch?v=x", { audioOnly: true }), [ + "mpv", + "--no-terminal", + "--no-video", + "--ytdl-format=bestaudio/best", + "https://www.youtube.com/watch?v=x", + ]); + assertEquals(mpvArgs("u", { title: "T" })[2], "--force-window=immediate"); + assertEquals( + unitArgs("backslash-youtube", "Backslash YouTube: T", { WAYLAND_DISPLAY: "wayland-1", DISPLAY: undefined }, [ + "mpv", + "u", + ]), + [ + "--user", + "--quiet", + "--collect", + "--unit=backslash-youtube", + "--description=Backslash YouTube: T", + "--setenv=WAYLAND_DISPLAY=wayland-1", + "mpv", + "u", + ], + ); + assertEquals(installCommand("debian", ["mpv", "yt-dlp"]), "sudo apt install mpv yt-dlp"); + assertEquals(installCommand(undefined, ["yt-dlp"]), "Install yt-dlp"); +}); + +Deno.test("video item", () => { + const item = videoItem(fromApi(rick), { account, enter: "browser" }); + assertEquals(item.icon, "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg"); + assertEquals(item.detail?.image, "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"); + assertEquals(item.accessories?.map((a) => a.text), ["1.6B", "3:33"]); + assertEquals(item.detail?.stats?.map((s) => s.value), ["1.6B", "3:33", "18M"]); + assertEquals(item.actions?.slice(0, 4).map((a) => a.title), [ + "Open in Browser", + "Play in mpv", + "Play Audio Only in mpv", + "Show Rick Astley Videos", + ]); + const mpvFirst = videoItem(fromApi(live), { account, enter: "mpv", playing: "jfKfPfyJRdk" }); + assertEquals(mpvFirst.actions?.[0].title, "Play in mpv"); + assertEquals(mpvFirst.accessories?.map((a) => a.tag ?? a.text), ["Playing", "LIVE", "90M"]); +}); diff --git a/extensions/youtube/player.ts b/extensions/youtube/player.ts new file mode 100644 index 0000000..e559995 --- /dev/null +++ b/extensions/youtube/player.ts @@ -0,0 +1,108 @@ +// Playing a video in mpv (which streams it with yt-dlp) as a transient systemd +// user unit, so it keeps playing once the launcher is hidden. + +import { exec } from "@backslash/api"; +import { load, save } from "./api.ts"; +import { type Family, mpvArgs, osFamily, unitArgs, type Video, watchUrl } from "./lib.ts"; + +export const UNIT = "backslash-youtube"; + +export interface Playing { + video: Video; + audioOnly: boolean; + started: number; +} + +async function installed(name: string): Promise<boolean> { + try { + await new Deno.Command(name, { + args: ["--version"], + stdin: "null", + stdout: "null", + stderr: "null", + signal: AbortSignal.timeout(3000), + }) + .output(); + return true; + } catch (e) { + return !(e instanceof Deno.errors.NotFound || e instanceof Deno.errors.PermissionDenied || + e instanceof Deno.errors.NotCapable); + } +} + +export interface Tools { + mpv: boolean; + ytdlp: boolean; +} + +export async function tools(): Promise<Tools> { + const [mpv, ytdlp] = await Promise.all([installed("mpv"), installed("yt-dlp")]); + return { mpv, ytdlp }; +} + +export async function distro(): Promise<Family | undefined> { + return osFamily(await Deno.readTextFile("/etc/os-release").catch(() => "")); +} + +async function unitActive(): Promise<boolean> { + try { + return (await exec("systemctl", ["--user", "is-active", `${UNIT}.service`])) === "active"; + } catch { + return false; + } +} + +export async function nowPlaying(): Promise<Playing | undefined> { + const saved = await load<Playing>("playing"); + if (!saved) return undefined; + if (await unitActive()) return saved; + await save("playing", null); + return undefined; +} + +export async function stop(): Promise<void> { + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + await save("playing", null); +} + +const env = (k: string) => { + try { + return Deno.env.get(k); + } catch { + return undefined; + } +}; + +export class MissingTool extends Error {} + +export async function play(video: Video, audioOnly: boolean): Promise<Playing> { + const t = await tools(); + if (!t.mpv || !t.ytdlp) { + const missing = [...(t.mpv ? [] : ["mpv"]), ...(t.ytdlp ? [] : ["yt-dlp"])]; + throw new MissingTool(`Playing needs ${missing.join(" and ")}`); + } + await exec("systemctl", ["--user", "stop", `${UNIT}.service`]).catch(() => {}); + const display = Object.fromEntries( + ["WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR"].map((k) => [k, env(k)]), + ); + try { + await exec( + "systemd-run", + unitArgs( + UNIT, + `Backslash YouTube: ${video.title}`, + display, + mpvArgs(watchUrl(video.id), { audioOnly, title: video.title }), + ), + ); + } catch (e) { + if (e instanceof Deno.errors.NotFound) throw new Error("systemd-run is needed to play in the background"); + if (String(e).includes("Failed to connect to bus")) { + throw new Error("No systemd user session: videos play in the background with systemd-run --user"); + } + throw e; + } + const playing = { video, audioOnly, started: Date.now() }; + await save("playing", playing); + return playing; +} diff --git a/extensions/youtube/views.ts b/extensions/youtube/views.ts new file mode 100644 index 0000000..938b28f --- /dev/null +++ b/extensions/youtube/views.ts @@ -0,0 +1,168 @@ +// Videos as launcher items, a channel's uploads and what's playing. + +import { + type Accessory, + type Action, + action, + close, + copy, + Icons, + type Item, + list, + type ListSpec, + meta, + type Metadata, + open, + refresh, + toast, +} from "@backslash/api"; +import { type Account, channelVideos, guarded } from "./api.ts"; +import { + ago, + channelUrl, + clock, + compact, + excerpt, + installCommand, + longDate, + shortUrl, + thumbnail, + type Video, + views, + watchUrl, +} from "./lib.ts"; +import { distro, MissingTool, play, type Playing, stop } from "./player.ts"; + +export interface Options { + account: Account; + enter: "browser" | "mpv"; + section?: string; + playing?: string; +} + +function playAction(v: Video, audioOnly: boolean): Action { + return action(audioOnly ? "Play Audio Only in mpv" : "Play in mpv", async () => { + try { + await play(v, audioOnly); + } catch (e) { + if (e instanceof MissingTool) { + const cmd = installCommand(await distro(), e.message.replace("Playing needs ", "").split(" and ")); + await toast(e.message, { style: "failure", message: cmd }); + return; + } + throw e; + } + return close(audioOnly ? `Playing the audio of ${v.title}` : `Playing ${v.title}`); + }, { icon: audioOnly ? Icons.headphones : Icons.play, ...(audioOnly ? { shortcut: "<Ctrl><Shift>p" } : {}) }); +} + +function videoMetadata(v: Video): Metadata[] { + const lines: Metadata[] = [meta.person("Channel", v.channel)]; + if (v.published) { + lines.push(meta.text("Published", `${longDate(v.published)} · ${ago(v.published)}`, { icon: Icons.calendar })); + } + if (v.seconds) lines.push(meta.text("Duration", clock(v.seconds)!, { icon: Icons.clock })); + if (v.live) { + lines.push(meta.tags("Status", [{ text: v.live === "live" ? "Live now" : "Upcoming", color: "red" }])); + } + if (v.views !== undefined) { + lines.push(meta.text("Views", new Intl.NumberFormat("en-US").format(v.views), { icon: Icons.eye })); + } + if (v.likes !== undefined) lines.push(meta.text("Likes", compact(v.likes), { icon: Icons.heart })); + if (v.comments !== undefined) lines.push(meta.text("Comments", compact(v.comments), { icon: Icons.comment })); + const badges = [...(v.hd ? ["HD"] : []), ...(v.captions ? ["CC"] : [])]; + if (badges.length) lines.push(meta.tags("Quality", badges)); + if (v.tags.length) lines.push(meta.tags("Tags", v.tags.slice(0, 6))); + lines.push(meta.separator(), meta.link("Link", `youtu.be/${v.id}`, watchUrl(v.id))); + return lines; +} + +export function videoItem(v: Video, o: Options): Item { + const openIt = open(watchUrl(v.id), "Open in Browser"); + const playing = o.playing === v.id; + const accessories: Accessory[] = []; + if (playing) accessories.push({ tag: "Playing", color: "green" }); + if (v.live) accessories.push({ tag: v.live === "live" ? "LIVE" : "Upcoming", color: "red" }); + if (v.views !== undefined) accessories.push({ text: compact(v.views), icon: Icons.eye, tooltip: views(v.views) }); + if (v.seconds) accessories.push({ text: clock(v.seconds), tooltip: "Duration" }); + const actions: Action[] = o.enter === "mpv" ? [playAction(v, false), openIt] : [openIt, playAction(v, false)]; + actions.push( + playAction(v, true), + ...(v.channelId + ? [ + action(`Show ${v.channel || "Channel"} Videos`, () => channelList(o.account, v.channelId!, v.channel, o), { + icon: Icons.person, + shortcut: "<Ctrl>u", + }), + open(channelUrl(v.channelId), "Open Channel in Browser", { section: "Open" }), + ] + : []), + copy(watchUrl(v.id), "Copy Link", { section: "Copy", shortcut: "<Ctrl><Shift>c" }), + copy(shortUrl(v.id), "Copy Short Link", { section: "Copy" }), + copy(`[${v.title}](${watchUrl(v.id)})`, "Copy as Markdown Link", { section: "Copy" }), + copy(v.title, "Copy Title", { section: "Copy" }), + ); + return { + id: `${o.section ?? ""}:${v.id}`, + title: v.title, + subtitle: [v.channel, ago(v.published)].filter(Boolean).join(" · "), + section: o.section, + icon: thumbnail(v.id, "mqdefault"), + keywords: [v.channel, ...v.tags.slice(0, 5)], + accessories, + detail: { + image: thumbnail(v.id, "hqdefault"), + stats: [ + ...(v.views !== undefined ? [{ label: "Views", value: compact(v.views) }] : []), + ...(v.seconds ? [{ label: "Duration", value: clock(v.seconds)! }] : []), + ...(v.likes !== undefined ? [{ label: "Likes", value: compact(v.likes) }] : []), + ], + markdown: [`**${v.title}**`, ...(excerpt(v.description) ? ["", excerpt(v.description)!] : [])].join("\n"), + metadata: videoMetadata(v), + }, + actions, + }; +} + +export function channelList(account: Account, channelId: string, name: string, o: Options): ListSpec { + return list({ + title: name || "Channel", + placeholder: `Filter ${name || "the channel"}'s latest videos…`, + empty: { title: "No videos", description: "This channel has no public uploads", icon: Icons.video }, + status: (items) => `${items.length} latest upload${items.length === 1 ? "" : "s"} · ${name}`, + actions: [open(channelUrl(channelId), "Open Channel in Browser")], + items: ({ signal }) => + guarded(async () => + (await channelVideos(account, channelId, signal)).map((v) => videoItem(v, { ...o, section: undefined })) + ), + }); +} + +export function nowPlayingItem(p: Playing): Item { + const v = p.video; + return { + id: "now-playing", + title: v.title, + subtitle: `${p.audioOnly ? "Audio only" : "Playing"} in mpv · ${v.channel}`, + section: "Now Playing", + icon: { source: p.audioOnly ? Icons.headphones : Icons.play, tint: "green" }, + detail: { + image: thumbnail(v.id, "hqdefault"), + metadata: [ + meta.text("Started", new Date(p.started).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }), { + icon: Icons.clock, + }), + ...videoMetadata(v), + ], + }, + actions: [ + action("Stop", async () => { + await stop(); + await toast("Stopped", { style: "success" }); + return refresh(); + }, { icon: Icons.stop, shortcut: "<Ctrl>period" }), + open(watchUrl(v.id), "Open in Browser"), + copy(watchUrl(v.id), "Copy Link", { section: "Copy" }), + ], + }; +} diff --git a/extensions/zotero/backslash.toml b/extensions/zotero/backslash.toml new file mode 100644 index 0000000..3e77dc8 --- /dev/null +++ b/extensions/zotero/backslash.toml @@ -0,0 +1,26 @@ +[extension] +id = "zotero" +title = "Zotero" +description = "Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs" +version = "1.0.0" +author = "Backslash" +icon = "bs-book" +license = "MIT" + +# While Zotero 7 runs with its local API on, items come from 127.0.0.1:23119. +# Otherwise ~/Zotero/zotero.sqlite (Zotero's default data directory) is +# copied to ~/.cache/backslash-zotero, because Zotero locks the original, and +# read with the sqlite3 program; nothing is ever written to the library. +[permissions] +net = ["127.0.0.1:23119"] +run = ["sqlite3"] +read = ["~/Zotero", "~/.cache/backslash-zotero", "/etc/os-release"] +write = ["~/.cache/backslash-zotero"] +env = ["HOME"] + +[[commands]] +name = "search" +title = "Search Zotero" +description = "Your references with authors, year and attachments; cite, open the PDF" +icon = "bs-book" +keywords = ["zotero", "citation", "bibliography", "reference", "paper", "bibtex", "pdf", "research", "biblio", "référence", "citer", "article"] diff --git a/extensions/zotero/commands/search.ts b/extensions/zotero/commands/search.ts new file mode 100644 index 0000000..569e737 --- /dev/null +++ b/extensions/zotero/commands/search.ts @@ -0,0 +1,27 @@ +import { Icons, list } from "@backslash/api"; +import { find, SetupError } from "../source.ts"; +import { setupItems, zoteroItem } from "../zotero.ts"; + +let footer = ""; + +export default list({ + placeholder: "Search by title, author, year or #tag…", + empty: { title: "No matching item", description: "Titles, creators and years; #tag for tags", icon: Icons.book }, + status: () => footer, + async search(query, { signal }) { + try { + const { items, kind, total } = await find(query, signal); + const source = kind === "api" + ? "Zotero local API" + : `Zotero database${total ? ` · ${total.toLocaleString("en")} items` : ""}`; + footer = `${ + query.trim() ? `${items.length} result${items.length === 1 ? "" : "s"}` : "Recently added" + } · ${source}`; + return items.map((i) => ({ ...zoteroItem(i, kind), section: query.trim() ? "Items" : "Recently Added" })); + } catch (e) { + if (!(e instanceof SetupError)) throw e; + footer = ""; + return await setupItems(e); + } + }, +}); diff --git a/extensions/zotero/lib.ts b/extensions/zotero/lib.ts new file mode 100644 index 0000000..1721617 --- /dev/null +++ b/extensions/zotero/lib.ts @@ -0,0 +1,541 @@ +// Zotero items from the local API (Zotero 7) or from the zotero.sqlite +// database, and their citations (APA-like and BibTeX). Pure functions. + +export const API = "http://127.0.0.1:23119/api"; + +export interface Creator { + creatorType: string; + firstName?: string; + lastName?: string; + /** Single-field names: organizations. */ + name?: string; +} + +export interface Attachment { + key: string; + title: string; + contentType?: string; + linkMode?: string; + /** Absolute path of the file, when it is on this computer. */ + path?: string; +} + +export interface ZItem { + key: string; + /** Zotero's group id for group libraries; undefined for "My Library". */ + groupId?: number; + itemType: string; + title: string; + creators: Creator[]; + fields: Record<string, string>; + tags: string[]; + attachments: Attachment[]; + dateAdded?: string; + dateModified?: string; +} + +// --- Mapping ----------------------------------------------------------------- + +const NOT_FIELDS = new Set([ + "key", + "version", + "itemType", + "creators", + "tags", + "collections", + "relations", + "dateAdded", + "dateModified", + "parentItem", + "deleted", +]); + +/** An item of the local (or web) API, `format=json`. */ +export function fromApi(raw: any, children: any[] = [], storageDir = ""): ZItem { + const d = raw?.data ?? {}; + const fields: Record<string, string> = {}; + for (const [k, v] of Object.entries(d)) { + if (!NOT_FIELDS.has(k) && typeof v === "string" && v) fields[k] = v; + } + const library = raw?.library ?? {}; + return { + key: String(raw?.key ?? d.key ?? ""), + groupId: library.type === "group" ? Number(library.id) : undefined, + itemType: String(d.itemType ?? "document"), + title: String(d.title ?? d.caseName ?? d.subject ?? d.nameOfAct ?? ""), + creators: Array.isArray(d.creators) ? d.creators : [], + fields, + tags: Array.isArray(d.tags) ? d.tags.map((t: any) => String(t.tag ?? t)).filter(Boolean) : [], + attachments: children.filter((c) => c?.data?.itemType === "attachment").map((c) => + attachmentFrom({ + key: c.key ?? c.data.key, + title: c.data.title, + contentType: c.data.contentType, + linkMode: c.data.linkMode, + path: c.data.path, + filename: c.data.filename, + }, storageDir) + ), + dateAdded: d.dateAdded, + dateModified: d.dateModified, + }; +} + +/** Where an attachment's file is: `storage:paper.pdf` and imported files are in `<data dir>/storage/<key>/`. */ +export function attachmentFrom( + a: { + key: string; + title?: string; + contentType?: string; + linkMode?: string | number; + path?: string; + filename?: string; + }, + storageDir: string, +): Attachment { + const mode = typeof a.linkMode === "number" ? LINK_MODES[a.linkMode] : a.linkMode; + let path: string | undefined; + if (a.path?.startsWith("storage:")) path = `${storageDir}/${a.key}/${a.path.slice(8)}`; + else if (a.path?.startsWith("/")) path = a.path; + else if (a.filename && (mode === "imported_file" || mode === "imported_url")) { + path = `${storageDir}/${a.key}/${a.filename}`; + } + return { + key: a.key, + title: a.title || a.filename || a.path?.replace(/^storage:/, "") || "Attachment", + contentType: a.contentType || undefined, + linkMode: mode, + path, + }; +} + +/** Zotero's `itemAttachments.linkMode` numbers. */ +const LINK_MODES = ["imported_file", "imported_url", "linked_file", "linked_url", "embedded_image"]; + +/** A row of the SQL query in `db.ts` (JSON columns as text, as `sqlite3 -json` prints them). */ +export interface DbRow { + key: string; + groupID: number | null; + typeName: string; + fields: string | null; + creators: string | null; + tags: string | null; + attachments: string | null; + dateAdded: string; + dateModified: string; +} + +const json = <T>(s: string | null | undefined, fallback: T): T => { + try { + return s ? JSON.parse(s) ?? fallback : fallback; + } catch { + return fallback; + } +}; + +export function fromDb(row: DbRow, storageDir: string): ZItem { + const fields = json<Record<string, string>>(row.fields, {}); + const creators = json<{ first: string; last: string; mode: number; type: string; i: number }[]>(row.creators, []) + .sort((a, b) => a.i - b.i) + .map((c) => + c.mode === 1 + ? { creatorType: c.type, name: c.last } + : { creatorType: c.type, firstName: c.first, lastName: c.last } + ); + const attachments = json<{ key: string; path: string; contentType: string; linkMode: number; title: string }[]>( + row.attachments, + [], + ).map((a) => attachmentFrom(a, storageDir)); + const title = fields.title ?? fields.caseName ?? fields.subject ?? fields.nameOfAct ?? ""; + return { + key: row.key, + groupId: row.groupID ?? undefined, + itemType: row.typeName, + title, + creators, + fields, + tags: json<string[]>(row.tags, []).filter(Boolean).sort((a, b) => a.localeCompare(b)), + attachments, + dateAdded: row.dateAdded ? `${row.dateAdded.replace(" ", "T")}Z` : undefined, + dateModified: row.dateModified ? `${row.dateModified.replace(" ", "T")}Z` : undefined, + }; +} + +// --- Fields ------------------------------------------------------------------ + +/** `2019-03-00 March 2019` (Zotero's stored dates), `2019-03-15`, `March 2019` → `2019`. */ +export function year(item: Pick<ZItem, "fields">): string | undefined { + return /\b(1[5-9]\d\d|2\d\d\d)\b/.exec(item.fields.date ?? "")?.[1]; +} + +/** Where it was published: the journal, book, proceedings, website… */ +export function publication(item: Pick<ZItem, "fields" | "itemType">): string | undefined { + const f = item.fields; + return f.publicationTitle ?? f.bookTitle ?? f.proceedingsTitle ?? f.websiteTitle ?? f.blogTitle ?? f.forumTitle ?? + f.encyclopediaTitle ?? f.dictionaryTitle ?? f.programTitle ?? f.seriesTitle ?? + (item.itemType === "thesis" ? f.university : undefined) ?? (item.itemType === "book" ? f.publisher : undefined); +} + +const TYPE_NAMES: Record<string, string> = { + journalArticle: "Journal Article", + bookSection: "Book Section", + conferencePaper: "Conference Paper", + webpage: "Web Page", + blogPost: "Blog Post", + forumPost: "Forum Post", + magazineArticle: "Magazine Article", + newspaperArticle: "Newspaper Article", + computerProgram: "Software", + preprint: "Preprint", + tvBroadcast: "TV Broadcast", +}; + +export function typeName(t: string): string { + return TYPE_NAMES[t] ?? t.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()); +} + +/** The people credited first: authors, else editors, else whoever comes first. */ +export function mainCreators(item: Pick<ZItem, "creators">): Creator[] { + const pick = (types: string[]) => item.creators.filter((c) => types.includes(c.creatorType)); + const a = pick([ + "author", + "inventor", + "artist", + "director", + "programmer", + "podcaster", + "presenter", + "sponsor", + "cartographer", + "composer", + "performer", + "interviewee", + ]); + if (a.length) return a; + const e = pick(["editor"]); + return e.length ? e : item.creators.slice(0, 1); +} + +export const fullName = (c: Creator) => c.name ?? [c.firstName, c.lastName].filter(Boolean).join(" "); + +/** "Lovelace", "Lovelace and Babbage", "Lovelace et al.", as Zotero shows them. */ +export function creatorSummary(item: Pick<ZItem, "creators">): string { + const people = mainCreators(item).map((c) => c.lastName || c.name || c.firstName || "").filter(Boolean); + if (people.length === 0) return ""; + if (people.length === 1) return people[0]; + if (people.length === 2) return `${people[0]} and ${people[1]}`; + return `${people[0]} et al.`; +} + +/** Zotero stores dates as `1843-00-00 1843` (sortable, then as typed): the typed part. */ +export function displayDate(date: string | undefined): string | undefined { + if (!date) return undefined; + const m = /^\d{4}-\d{2}-\d{2} (.+)$/.exec(date); + return m ? m[1] : date; +} + +// --- Citations --------------------------------------------------------------- + +/** `Ada King` → `A. K.`; `Jean-Paul` → `J.-P.` */ +export function initials(first: string): string { + return first.trim().split(/\s+/).filter(Boolean).map((w) => + w.split("-").map((p) => (p ? `${[...p][0].toUpperCase()}.` : "")).join("-") + ).join(" "); +} + +function apaName(c: Creator): string { + if (c.name || !c.lastName) return c.name ?? c.firstName ?? ""; + return c.firstName ? `${c.lastName}, ${initials(c.firstName)}` : c.lastName; +} + +/** APA 7: up to 20 names, the last one after “&”; 21+: the first 19, “…”, the last. */ +export function apaAuthors(creators: Creator[]): string { + const names = creators.map(apaName).filter(Boolean); + if (names.length === 0) return ""; + if (names.length === 1) return names[0]; + if (names.length > 20) return `${names.slice(0, 19).join(", ")}, … ${names[names.length - 1]}`; + return `${names.slice(0, -1).join(", ")}, & ${names[names.length - 1]}`; +} + +const end = (s: string) => (/[.?!]$/.test(s) ? s : `${s}.`); + +function doiUrl(doi: string | undefined): string | undefined { + if (!doi) return undefined; + const clean = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//i, "").replace(/^doi:\s*/i, ""); + return `https://doi.org/${clean}`; +} + +/** + * An APA-style reference in plain text, close to what Zotero's APA 7 style + * gives for the common types (articles, books, chapters, papers, pages). + */ +export function apaCitation(item: ZItem): string { + const f = item.fields; + const authors = mainCreators(item); + const editorsOnly = authors.length > 0 && authors.every((c) => c.creatorType === "editor"); + let who = apaAuthors(authors); + if (who && editorsOnly) who += authors.length > 1 ? " (Eds.)" : " (Ed.)"; + const y = year(item) ?? "n.d."; + const title = (item.title || "Untitled").trim(); + const parts: string[] = []; + const dateText = `(${y}).`; + if (who) parts.push(end(who), dateText, end(title)); + else parts.push(end(title), dateText); + const pub = publication(item); + const pages = f.pages?.replace(/-+/g, "–"); + switch (item.itemType) { + case "journalArticle": + case "magazineArticle": + case "newspaperArticle": { + let source = pub ?? ""; + if (f.volume) source += `${source ? ", " : ""}${f.volume}`; + if (f.issue) source += `(${f.issue})`; + if (pages) source += `${source ? ", " : ""}${pages}`; + if (source) parts.push(end(source)); + break; + } + case "book": { + if (f.edition && !/^1(st)?$/i.test(f.edition)) { + parts[parts.length - 1] = `${title} (${ordinalEdition(f.edition)} ed.).`; + } + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "bookSection": + case "encyclopediaArticle": + case "dictionaryEntry": { + const editors = item.creators.filter((c) => c.creatorType === "editor"); + const eds = editors.length + ? `${ + editors.map((c) => [c.firstName ? initials(c.firstName) : "", c.lastName ?? c.name].filter(Boolean).join(" ")) + .join(", ") + } (${editors.length > 1 ? "Eds." : "Ed."}), ` + : ""; + if (pub) parts.push(`In ${eds}${pub}${pages ? ` (pp. ${pages})` : ""}.`); + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "conferencePaper": { + if (pub) parts.push(`${pub}${pages ? ` (pp. ${pages})` : ""}.`); + if (f.publisher) parts.push(end(f.publisher)); + break; + } + case "thesis": { + const kind = f.thesisType || "Thesis"; + parts[parts.length - 1] = `${title} [${kind}${f.university ? `, ${f.university}` : ""}].`; + break; + } + case "report": { + if (f.reportNumber) parts[parts.length - 1] = `${title} (${f.reportNumber}).`; + if (f.institution) parts.push(end(f.institution)); + break; + } + default: + if (pub) parts.push(end(pub)); + else if (f.publisher) parts.push(end(f.publisher)); + } + const link = doiUrl(f.DOI) ?? f.url; + if (link) parts.push(link); + return parts.join(" ").replace(/\s+/g, " ").trim(); +} + +function ordinalEdition(e: string): string { + if (!/^\d+$/.test(e)) return e; + const n = Number(e); + const s = n % 100 >= 11 && n % 100 <= 13 + ? "th" + : ({ 1: "st", 2: "nd", 3: "rd" } as Record<number, string>)[n % 10] ?? "th"; + return `${n}${s}`; +} + +/** Author-year in the text: `(Lovelace, 1843)`, `(Lovelace & Babbage, 1843)`, `(Lovelace et al., 1843)`. */ +export function inTextCitation(item: ZItem): string { + const people = mainCreators(item).map((c) => c.lastName || c.name || "").filter(Boolean); + const who = people.length === 0 + ? item.title.split(/\s+/).slice(0, 3).join(" ") + : people.length === 1 + ? people[0] + : people.length === 2 + ? `${people[0]} & ${people[1]}` + : `${people[0]} et al.`; + return `(${who}, ${year(item) ?? "n.d."})`; +} + +const BIBTEX_TYPES: Record<string, string> = { + journalArticle: "article", + magazineArticle: "article", + newspaperArticle: "article", + book: "book", + bookSection: "incollection", + conferencePaper: "inproceedings", + thesis: "phdthesis", + report: "techreport", + manuscript: "unpublished", + preprint: "misc", +}; + +/** A citation key: `Citation Key:` in Extra (Better BibTeX), else `lovelace1843sketch`. */ +export function citationKey(item: ZItem): string { + const pinned = item.fields.citationKey ?? /^\s*citation key:\s*(\S+)/im.exec(item.fields.extra ?? "")?.[1]; + if (pinned) return pinned; + const ascii = (s: string) => + s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]/g, ""); + const first = mainCreators(item)[0]; + const who = ascii(first?.lastName ?? first?.name?.split(/\s+/)[0] ?? "") || "anon"; + const STOP = new Set([ + "a", + "an", + "the", + "on", + "of", + "in", + "and", + "for", + "to", + "le", + "la", + "les", + "un", + "une", + "de", + "der", + "die", + "das", + ]); + const word = item.title.split(/\s+/).map(ascii).find((w) => w && !STOP.has(w)) ?? ""; + return `${who}${year(item) ?? ""}${word}`; +} + +/** Braces for case, and LaTeX's special characters escaped. */ +function bibValue(s: string): string { + return s.replace(/\\/g, "\\textbackslash{}").replace(/([{}&%$#_])/g, "\\$1").replace(/~/g, "\\textasciitilde{}") + .replace(/\^/g, "\\textasciicircum{}"); +} + +const bibName = (c: Creator) => + c.name ? `{${bibValue(c.name)}}` : [c.lastName, c.firstName].filter(Boolean).map((s) => bibValue(s!)).join(", "); + +export function bibtex(item: ZItem): string { + const f = item.fields; + const type = item.itemType === "thesis" && /master/i.test(f.thesisType ?? "") + ? "mastersthesis" + : BIBTEX_TYPES[item.itemType] ?? "misc"; + const authors = item.creators.filter((c) => c.creatorType !== "editor" && c.creatorType !== "seriesEditor"); + const editors = item.creators.filter((c) => c.creatorType === "editor"); + const pub = publication(item); + const entries: [string, string | undefined][] = [ + ["title", item.title ? `{${bibValue(item.title)}}` : undefined], + ["author", authors.length ? authors.map(bibName).join(" and ") : undefined], + ["editor", editors.length ? editors.map(bibName).join(" and ") : undefined], + [ + type === "article" + ? "journal" + : type === "incollection" || type === "inproceedings" + ? "booktitle" + : "howpublished", + type === "book" || type.endsWith("thesis") ? undefined : pub, + ], + ["year", year(item)], + ["volume", f.volume], + ["number", f.issue ?? f.reportNumber], + ["pages", f.pages?.replace(/[–—-]+/g, "--")], + ["edition", f.edition], + ["publisher", f.publisher], + ["address", f.place], + ["school", type.endsWith("thesis") ? f.university : undefined], + ["institution", type === "techreport" ? f.institution : undefined], + ["doi", f.DOI], + ["isbn", f.ISBN], + ["issn", f.ISSN], + ["url", f.url], + ["urldate", f.accessDate?.slice(0, 10)], + ["keywords", item.tags.length ? item.tags.join(", ") : undefined], + ]; + const lines = entries + .filter(([, v]) => v) + .map(([k, v]) => + ` ${k} = {${ + k === "url" || k === "doi" ? v : k === "title" || k === "author" || k === "editor" ? v : bibValue(v!) + }},` + ); + return `@${type}{${citationKey(item)},\n${lines.join("\n")}\n}`; +} + +// --- Searching (database mode) ---------------------------------------------- + +const fold = (s: string) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + +/** + * Every word must match the title, a creator, the year, a tag or the + * publication; `#word` must match a tag. Title matches rank first. + */ +export function matchScore(item: ZItem, query: string): number { + const words = fold(query).split(/\s+/).filter(Boolean); + if (!words.length) return 1; + const title = fold(item.title); + const people = fold(item.creators.map(fullName).join(" ")); + const tags = item.tags.map(fold); + const rest = fold([year(item), publication(item), item.fields.DOI, citationKey(item)].filter(Boolean).join(" ")); + let score = 0; + for (const w of words) { + if (w.startsWith("#")) { + const t = w.slice(1); + if (!t) continue; + if (tags.some((x) => x === t)) score += 4; + else if (tags.some((x) => x.startsWith(t))) score += 2; + else return 0; + } else if (new RegExp(`(^|\\W)${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(title)) score += 5; + else if (title.includes(w)) score += 3; + else if (people.includes(w)) score += 4; + else if (tags.some((x) => x.includes(w))) score += 2; + else if (rest.includes(w)) score += 1; + else return 0; + } + return score; +} + +export function search(items: ZItem[], query: string, limit = 50): ZItem[] { + if (!query.trim()) { + return [...items].sort((a, b) => (b.dateAdded ?? "").localeCompare(a.dateAdded ?? "")).slice(0, limit); + } + return items + .map((item) => ({ item, s: matchScore(item, query) })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s || (b.item.dateModified ?? "").localeCompare(a.item.dateModified ?? "")) + .slice(0, limit) + .map((x) => x.item); +} + +/** Local API parameters for what is typed: `#tag` words become tag filters. */ +export function apiParams(query: string): Record<string, string | string[]> { + const words = query.trim().split(/\s+/).filter(Boolean); + const tags = words.filter((w) => w.startsWith("#") && w.length > 1).map((w) => w.slice(1)); + const text = words.filter((w) => !w.startsWith("#")).join(" "); + const params: Record<string, string | string[]> = { format: "json", limit: "40", itemType: "-attachment" }; + if (text) Object.assign(params, { q: text, qmode: "titleCreatorYear", sort: "dateModified", direction: "desc" }); + else Object.assign(params, { sort: "dateAdded", direction: "desc" }); + if (tags.length) params.tag = tags; + return params; +} + +// --- Links ------------------------------------------------------------------- + +export function selectUrl(item: Pick<ZItem, "key" | "groupId">): string { + return item.groupId + ? `zotero://select/groups/${item.groupId}/items/${item.key}` + : `zotero://select/library/items/${item.key}`; +} + +export function openPdfUrl(item: Pick<ZItem, "groupId">, attachment: Pick<Attachment, "key">): string { + return item.groupId + ? `zotero://open-pdf/groups/${item.groupId}/items/${attachment.key}` + : `zotero://open-pdf/library/items/${attachment.key}`; +} + +/** The PDF to open: the first one with a file, else the first PDF. */ +export function mainPdf(item: Pick<ZItem, "attachments">): Attachment | undefined { + const pdfs = item.attachments.filter((a) => a.contentType === "application/pdf" || /\.pdf$/i.test(a.path ?? "")); + return pdfs.find((a) => a.path) ?? pdfs[0]; +} + +export { doiUrl }; diff --git a/extensions/zotero/lib_test.ts b/extensions/zotero/lib_test.ts new file mode 100644 index 0000000..6417bc7 --- /dev/null +++ b/extensions/zotero/lib_test.ts @@ -0,0 +1,366 @@ +import { assert, assertEquals } from "@std/assert"; +import { + apaAuthors, + apaCitation, + apiParams, + attachmentFrom, + bibtex, + citationKey, + creatorSummary, + type DbRow, + displayDate, + fromApi, + fromDb, + initials, + inTextCitation, + mainPdf, + matchScore, + openPdfUrl, + publication, + search, + selectUrl, + typeName, + year, + type ZItem, +} from "./lib.ts"; +import { queryFor } from "./source.ts"; +import { sqliteInstall, zoteroItem } from "./zotero.ts"; + +const STORAGE = "/home/me/Zotero/storage"; + +// A row as `sqlite3 -json` prints it for the query in source.ts. +const ROW: DbRow = { + key: "ABCD1234", + groupID: null, + typeName: "journalArticle", + fields: JSON.stringify({ + title: "Sketch of the Analytical Engine", + date: "1843-00-00 1843", + publicationTitle: "Scientific Memoirs", + volume: "3", + issue: "2", + pages: "666-731", + DOI: "https://doi.org/10.1000/xyz123", + }), + creators: JSON.stringify([ + { first: "Luigi F.", last: "Menabrea", mode: 0, type: "author", i: 1 }, + { first: "Ada", last: "Lovelace", mode: 0, type: "author", i: 0 }, + ]), + tags: JSON.stringify(["history", "computing"]), + attachments: JSON.stringify([ + { key: "ATT1", path: "storage:Sketch.pdf", contentType: "application/pdf", linkMode: 0, title: "Full Text PDF" }, + { key: "ATT2", path: null, contentType: "text/html", linkMode: 3, title: "Snapshot link" }, + ]), + dateAdded: "2026-10-01 10:00:00", + dateModified: "2026-10-02 10:00:00", +}; + +const article = fromDb(ROW, STORAGE); + +const book = (extra: Partial<ZItem> = {}): ZItem => ({ + key: "BOOK0001", + itemType: "book", + title: "The Art of Computer Programming", + creators: [{ creatorType: "author", firstName: "Donald E.", lastName: "Knuth" }], + fields: { date: "1997", publisher: "Addison-Wesley", place: "Reading, MA", edition: "3" }, + tags: [], + attachments: [], + ...extra, +}); + +Deno.test("database rows: creators in order, dates, attachments in storage", () => { + assertEquals(article.creators.map((c) => c.lastName), ["Lovelace", "Menabrea"]); + assertEquals(article.tags, ["computing", "history"]); + assertEquals(article.attachments[0], { + key: "ATT1", + title: "Full Text PDF", + contentType: "application/pdf", + linkMode: "imported_file", + path: `${STORAGE}/ATT1/Sketch.pdf`, + }); + assertEquals(article.attachments[1].path, undefined); + assertEquals(article.dateAdded, "2026-10-01T10:00:00Z"); + assertEquals(year(article), "1843"); + assertEquals(displayDate(article.fields.date), "1843"); + assertEquals(displayDate("March 2019"), "March 2019"); + // Organizations (single-field names) and broken JSON. + const org = fromDb({ + ...ROW, + creators: JSON.stringify([{ first: "", last: "World Health Organization", mode: 1, type: "author", i: 0 }]), + tags: "oops", + }, STORAGE); + assertEquals(org.creators, [{ creatorType: "author", name: "World Health Organization" }]); + assertEquals(org.tags, []); + assertEquals(fromDb({ ...ROW, groupID: 4242 }, STORAGE).groupId, 4242); +}); + +Deno.test("local API items", () => { + const raw = { + key: "ABCD1234", + library: { type: "group", id: 7 }, + meta: { numChildren: 1 }, + data: { + key: "ABCD1234", + itemType: "journalArticle", + title: "T", + creators: [{ creatorType: "author", firstName: "Ada", lastName: "Lovelace" }], + date: "1843", + tags: [{ tag: "computing" }, { tag: "history", type: 1 }], + collections: ["X"], + relations: {}, + extra: "", + }, + }; + const child = { + key: "ATT1", + data: { + itemType: "attachment", + linkMode: "imported_file", + filename: "a.pdf", + contentType: "application/pdf", + title: "PDF", + }, + }; + const linked = { + key: "ATT2", + data: { + itemType: "attachment", + linkMode: "linked_file", + path: "/home/me/Papers/b.pdf", + contentType: "application/pdf", + title: "B", + }, + }; + const note = { key: "N", data: { itemType: "note" } }; + const item = fromApi(raw, [child, linked, note], STORAGE); + assertEquals(item.groupId, 7); + assertEquals(item.tags, ["computing", "history"]); + assertEquals(item.fields, { title: "T", date: "1843" }); + assertEquals(item.attachments.map((a) => a.path), [`${STORAGE}/ATT1/a.pdf`, "/home/me/Papers/b.pdf"]); + assertEquals(attachmentFrom({ key: "K", linkMode: "linked_url", title: "Site" }, STORAGE).path, undefined); +}); + +Deno.test("names, types and publications", () => { + assertEquals(initials("Ada"), "A."); + assertEquals(initials("Luigi F."), "L. F."); + assertEquals(initials("Jean-Paul"), "J.-P."); + assertEquals(initials("Émile"), "É."); + assertEquals(creatorSummary(article), "Lovelace and Menabrea"); + assertEquals(creatorSummary(book()), "Knuth"); + assertEquals( + creatorSummary({ creators: [1, 2, 3].map((n) => ({ creatorType: "author", lastName: `L${n}` })) }), + "L1 et al.", + ); + assertEquals(typeName("journalArticle"), "Journal Article"); + assertEquals(typeName("videoRecording"), "Video Recording"); + assertEquals(publication(article), "Scientific Memoirs"); + assertEquals(publication(book()), "Addison-Wesley"); +}); + +Deno.test("APA authors: ampersand, 20 names, organizations", () => { + const people = (n: number) => + Array.from({ length: n }, (_, i) => ({ creatorType: "author", firstName: "A", lastName: `L${i + 1}` })); + assertEquals(apaAuthors(people(1)), "L1, A."); + assertEquals(apaAuthors(people(2)), "L1, A., & L2, A."); + assertEquals(apaAuthors(people(3)), "L1, A., L2, A., & L3, A."); + const many = apaAuthors(people(25)); + assert(many.startsWith("L1, A., L2, A.,")); + assert(many.endsWith("L19, A., … L25, A.")); + assertEquals(apaAuthors([{ creatorType: "author", name: "World Health Organization" }]), "World Health Organization"); +}); + +Deno.test("APA references for common types", () => { + assertEquals( + apaCitation(article), + "Lovelace, A., & Menabrea, L. F. (1843). Sketch of the Analytical Engine. Scientific Memoirs, 3(2), 666–731. https://doi.org/10.1000/xyz123", + ); + assertEquals(apaCitation(book()), "Knuth, D. E. (1997). The Art of Computer Programming (3rd ed.). Addison-Wesley."); + assertEquals( + apaCitation({ + key: "S", + itemType: "bookSection", + title: "On Computable Numbers", + creators: [{ creatorType: "author", firstName: "Alan", lastName: "Turing" }, { + creatorType: "editor", + firstName: "B. Jack", + lastName: "Copeland", + }], + fields: { date: "2004", bookTitle: "The Essential Turing", pages: "58-90", publisher: "Oxford University Press" }, + tags: [], + attachments: [], + }), + "Turing, A. (2004). On Computable Numbers. In B. J. Copeland (Ed.), The Essential Turing (pp. 58–90). Oxford University Press.", + ); + assertEquals( + apaCitation({ + key: "W", + itemType: "webpage", + title: "Zotero Documentation", + creators: [], + fields: { url: "https://www.zotero.org/support/", websiteTitle: "Zotero" }, + tags: [], + attachments: [], + }), + "Zotero Documentation. (n.d.). Zotero. https://www.zotero.org/support/", + ); + assertEquals( + apaCitation({ + key: "T", + itemType: "thesis", + title: "Deep Things", + creators: [{ creatorType: "author", firstName: "Grace", lastName: "Hopper" }], + fields: { date: "1934", thesisType: "Doctoral dissertation", university: "Yale University" }, + tags: [], + attachments: [], + }), + "Hopper, G. (1934). Deep Things [Doctoral dissertation, Yale University].", + ); + assertEquals( + apaCitation( + book({ creators: [{ creatorType: "editor", firstName: "Ann", lastName: "Ed" }], fields: { date: "2001" } }), + ), + "Ed, A. (Ed.). (2001). The Art of Computer Programming.", + ); + assertEquals(inTextCitation(article), "(Lovelace & Menabrea, 1843)"); + assertEquals(inTextCitation(book({ fields: {} })), "(Knuth, n.d.)"); +}); + +Deno.test("citation keys and BibTeX", () => { + assertEquals(citationKey(article), "lovelace1843sketch"); + assertEquals(citationKey(book()), "knuth1997art"); + assertEquals(citationKey(book({ fields: { extra: "Citation Key: knuthTAOCP\nother" } })), "knuthTAOCP"); + assertEquals( + citationKey( + book({ + creators: [{ creatorType: "author", firstName: "Kurt", lastName: "Gödel" }], + title: "Über formal unentscheidbare Sätze", + fields: { date: "1931" }, + }), + ), + "godel1931uber", + ); + assertEquals( + bibtex(article), + [ + "@article{lovelace1843sketch,", + " title = {{Sketch of the Analytical Engine}},", + " author = {Lovelace, Ada and Menabrea, Luigi F.},", + " journal = {Scientific Memoirs},", + " year = {1843},", + " volume = {3},", + " number = {2},", + " pages = {666--731},", + " doi = {https://doi.org/10.1000/xyz123},", + " keywords = {computing, history},", + "}", + ].join("\n"), + ); + const b = bibtex(book({ title: "R&D: 100% {fun}" })); + assert(b.startsWith("@book{knuth1997rd,")); + assert(b.includes("title = {{R\\&D: 100\\% \\{fun\\}}},")); + assert(b.includes("publisher = {Addison-Wesley},")); + assert(b.includes("address = {Reading, MA},")); + assert(!b.includes("howpublished")); + assert( + bibtex({ ...book(), itemType: "thesis", fields: { thesisType: "Master's thesis", university: "MIT" } }).startsWith( + "@mastersthesis{", + ), + ); +}); + +Deno.test("searching the database copy", () => { + const items = [article, book({ tags: ["algorithms"], dateAdded: "2026-10-20T08:00:00Z" })]; + assertEquals(search(items, "").map((i) => i.key), ["BOOK0001", "ABCD1234"]); + assertEquals(search(items, "lovelace").map((i) => i.key), ["ABCD1234"]); + assertEquals(search(items, "analytical eng").map((i) => i.key), ["ABCD1234"]); + assertEquals(search(items, "#algo").map((i) => i.key), ["BOOK0001"]); + assertEquals(search(items, "knuth 1997").map((i) => i.key), ["BOOK0001"]); + assertEquals(search(items, "knuth 1843"), []); + assertEquals(matchScore(article, "#nope"), 0); + assert(matchScore(article, "sketch") > matchScore(article, "memoirs")); +}); + +Deno.test("local API parameters", () => { + assertEquals(apiParams("lovelace #computing #history"), { + format: "json", + limit: "40", + itemType: "-attachment", + q: "lovelace", + qmode: "titleCreatorYear", + sort: "dateModified", + direction: "desc", + tag: ["computing", "history"], + }); + assertEquals(apiParams("").sort, "dateAdded"); +}); + +Deno.test("links and PDFs", () => { + assertEquals(selectUrl(article), "zotero://select/library/items/ABCD1234"); + assertEquals(selectUrl({ key: "K", groupId: 9 }), "zotero://select/groups/9/items/K"); + assertEquals(mainPdf(article)?.key, "ATT1"); + assertEquals(openPdfUrl(article, mainPdf(article)!), "zotero://open-pdf/library/items/ATT1"); + assertEquals(mainPdf(book()), undefined); +}); + +Deno.test("item and setup", () => { + const item = zoteroItem(article, "db"); + assertEquals(item.subtitle, "Lovelace and Menabrea · Scientific Memoirs"); + assertEquals(item.actions?.slice(0, 4).map((a) => a.title), [ + "Show in Zotero", + "Open PDF in Zotero", + "Open PDF in Default App", + "Copy Citation", + ]); + assertEquals(sqliteInstall("ID=fedora"), "sudo dnf install sqlite"); + assertEquals(sqliteInstall('ID=pop\nID_LIKE="ubuntu debian"'), "sudo apt install sqlite3"); + assertEquals(sqliteInstall("ID=arch"), "sudo pacman -S sqlite"); +}); + +Deno.test("the SQL query runs against a Zotero-shaped database", async () => { + // Only when a sqlite3 program is installed (CI may not have it). + let ok = false; + try { + ok = (await new Deno.Command("sqlite3", { args: ["-version"], stdout: "null", stderr: "null" }).output()).success; + } catch { /* not installed */ } + if (!ok) return; + const dir = await Deno.makeTempDir(); + try { + const schema = ` +CREATE TABLE itemTypesCombined (itemTypeID INT, typeName TEXT); +CREATE TABLE fieldsCombined (fieldID INT, fieldName TEXT); +CREATE TABLE items (itemID INTEGER PRIMARY KEY, itemTypeID INT, dateAdded TEXT, dateModified TEXT, libraryID INT, key TEXT); +CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value); +CREATE TABLE itemData (itemID INT, fieldID INT, valueID INT); +CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, fieldMode INT); +CREATE TABLE creatorTypes (creatorTypeID INT, creatorType TEXT); +CREATE TABLE itemCreators (itemID INT, creatorID INT, creatorTypeID INT, orderIndex INT); +CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT); +CREATE TABLE itemTags (itemID INT, tagID INT, type INT); +CREATE TABLE itemAttachments (itemID INT, parentItemID INT, linkMode INT, contentType TEXT, path TEXT); +CREATE TABLE deletedItems (itemID INT); +CREATE TABLE groups (groupID INT, libraryID INT); +INSERT INTO itemTypesCombined VALUES (1, 'book'), (2, 'attachment'); +INSERT INTO fieldsCombined VALUES (1, 'title'); +INSERT INTO items VALUES (1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', 1, 'BOOK'), (2, 2, '', '', 1, 'ATT'), (3, 1, '', '', 1, 'GONE'); +INSERT INTO itemDataValues VALUES (1, 'A Book'), (2, 'PDF'); +INSERT INTO itemData VALUES (1, 1, 1), (2, 1, 2); +INSERT INTO creators VALUES (1, 'Ada', 'Lovelace', 0); +INSERT INTO creatorTypes VALUES (1, 'author'); +INSERT INTO itemCreators VALUES (1, 1, 1, 0); +INSERT INTO itemAttachments VALUES (2, 1, 0, 'application/pdf', 'storage:a.pdf'); +INSERT INTO deletedItems VALUES (3);`; + const db = `${dir}/z.sqlite`; + await new Deno.Command("sqlite3", { args: [db, schema] }).output(); + const out = await new Deno.Command("sqlite3", { args: ["-json", db], stdin: "piped", stdout: "piped" }).spawn(); + const w = out.stdin.getWriter(); + await w.write(new TextEncoder().encode(queryFor(true))); + await w.close(); + const rows = JSON.parse(new TextDecoder().decode((await out.output()).stdout)) as DbRow[]; + const items = rows.map((r) => fromDb(r, STORAGE)); + assertEquals(items.map((i) => [i.key, i.title, i.creators[0]?.lastName, i.attachments[0]?.path]), [ + ["BOOK", "A Book", "Lovelace", `${STORAGE}/ATT/a.pdf`], + ]); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); diff --git a/extensions/zotero/source.ts b/extensions/zotero/source.ts new file mode 100644 index 0000000..467c3f8 --- /dev/null +++ b/extensions/zotero/source.ts @@ -0,0 +1,156 @@ +// Where items come from: Zotero 7's local API while Zotero runs with it +// enabled, else a copy of zotero.sqlite read with the sqlite3 program. + +import { exec } from "@backslash/api"; +import { API, apiParams, type DbRow, fromApi, fromDb, search, type ZItem } from "./lib.ts"; + +export const HOME = Deno.env.get("HOME") ?? ""; +export const DATA_DIR = `${HOME}/Zotero`; +export const STORAGE_DIR = `${DATA_DIR}/storage`; +const DB = `${DATA_DIR}/zotero.sqlite`; +/** Zotero locks its database while it runs: it is read from a copy, refreshed when it changes. */ +const COPY_DIR = `${HOME}/.cache/backslash-zotero`; + +export type Kind = "api" | "db"; + +export class SetupError extends Error { + constructor(message: string, readonly reason: "no-sqlite" | "no-library") { + super(message); + } +} + +// --- Local API ---------------------------------------------------------------- + +/** `undefined` when the local API can't be used (Zotero closed, too old, or the API is off). */ +export async function apiSearch(query: string, signal?: AbortSignal): Promise<ZItem[] | undefined> { + const url = new URL(`${API}/users/0/items/top`); + for (const [k, v] of Object.entries(apiParams(query))) { + for (const x of Array.isArray(v) ? v : [v]) url.searchParams.append(k, x); + } + let res: Response; + try { + res = await fetch(url, { signal: signal ?? AbortSignal.timeout(5000), headers: { "Zotero-API-Version": "3" } }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError" && signal?.aborted) throw e; + return undefined; + } + if (!res.ok) { + await res.body?.cancel(); + return undefined; // 403 "Local API is not enabled", or 404 before Zotero 7 + } + const raw = (await res.json()) as any[]; + const items = raw.filter((r) => !["note", "attachment", "annotation"].includes(r?.data?.itemType)); + const children = await Promise.all(items.map(async (r) => { + if (!r?.meta?.numChildren) return []; + const c = await fetch(`${API}/users/0/items/${r.key}/children?format=json`, { signal }).catch(() => undefined); + return c?.ok ? await c.json() as any[] : []; + })); + return items.map((r, i) => fromApi(r, children[i], STORAGE_DIR)); +} + +/** Zotero's own BibTeX export of an item, if the local API is on. */ +export async function apiBibtex(item: ZItem): Promise<string | undefined> { + if (item.groupId) return undefined; + try { + const res = await fetch(`${API}/users/0/items/${item.key}?format=bibtex`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return undefined; + const text = (await res.text()).trim(); + return text.startsWith("@") ? text : undefined; + } catch { + return undefined; + } +} + +// --- Database ----------------------------------------------------------------- + +const VALUE = (field: string, item: string) => + `(SELECT v.value FROM itemData d JOIN FIELDS f ON f.fieldID = d.fieldID JOIN itemDataValues v ON v.valueID = d.valueID WHERE d.itemID = ${item} AND f.fieldName = '${field}')`; + +/** Top-level items of every library, not in the trash, with their fields, creators, tags and attachments. */ +export const QUERY = ` +SELECT i.key AS key, g.groupID AS groupID, t.typeName AS typeName, + (SELECT json_group_object(f.fieldName, v.value) FROM itemData d + JOIN FIELDS f ON f.fieldID = d.fieldID JOIN itemDataValues v ON v.valueID = d.valueID + WHERE d.itemID = i.itemID) AS fields, + (SELECT json_group_array(json_object('first', c.firstName, 'last', c.lastName, 'mode', c.fieldMode, + 'type', ct.creatorType, 'i', ic.orderIndex)) FROM itemCreators ic + JOIN creators c ON c.creatorID = ic.creatorID JOIN creatorTypes ct ON ct.creatorTypeID = ic.creatorTypeID + WHERE ic.itemID = i.itemID) AS creators, + (SELECT json_group_array(tg.name) FROM itemTags it JOIN tags tg ON tg.tagID = it.tagID + WHERE it.itemID = i.itemID) AS tags, + (SELECT json_group_array(json_object('key', ai.key, 'path', a.path, 'contentType', a.contentType, + 'linkMode', a.linkMode, 'title', ${VALUE("title", "a.itemID")})) FROM itemAttachments a + JOIN items ai ON ai.itemID = a.itemID + WHERE a.parentItemID = i.itemID AND a.itemID NOT IN (SELECT itemID FROM deletedItems)) AS attachments, + i.dateAdded AS dateAdded, i.dateModified AS dateModified +FROM items i +JOIN TYPES t ON t.itemTypeID = i.itemTypeID +LEFT JOIN groups g ON g.libraryID = i.libraryID +WHERE t.typeName NOT IN ('attachment', 'note', 'annotation') + AND i.itemID NOT IN (SELECT itemID FROM deletedItems); +`; + +/** Zotero 5+ keeps custom fields and types in the *Combined tables; older schemas only have the plain ones. */ +export const queryFor = (combined: boolean) => + QUERY.replaceAll("JOIN FIELDS", combined ? "JOIN fieldsCombined" : "JOIN fields") + .replaceAll("JOIN TYPES", combined ? "JOIN itemTypesCombined" : "JOIN itemTypes"); + +const mtime = (path: string) => Deno.stat(path).then((s) => s.mtime?.getTime() ?? 0, () => -1); + +/** A fresh copy of the database (and its write-ahead log), made only when Zotero changed it. */ +async function copyDb(): Promise<string> { + const source = await mtime(DB); + if (source < 0) throw new SetupError("No Zotero library in ~/Zotero", "no-library"); + const copy = `${COPY_DIR}/zotero.sqlite`; + const wal = Math.max(await mtime(`${DB}-wal`), source); + if ((await mtime(copy)) >= wal) return copy; + await Deno.mkdir(COPY_DIR, { recursive: true }); + await Deno.copyFile(DB, `${copy}.tmp`); + await Deno.remove(`${copy}-wal`).catch(() => {}); + await Deno.remove(`${copy}-shm`).catch(() => {}); + if ((await mtime(`${DB}-wal`)) >= 0) await Deno.copyFile(`${DB}-wal`, `${copy}-wal`); + await Deno.rename(`${copy}.tmp`, copy); + return copy; +} + +async function sqlite(path: string, sql: string, signal?: AbortSignal): Promise<DbRow[]> { + let out: string; + try { + out = await exec("sqlite3", ["-json", path], { stdin: sql, signal }); + } catch (e) { + if (e instanceof Deno.errors.NotFound) { + throw new SetupError("Reading the Zotero database needs the sqlite3 program", "no-sqlite"); + } + throw e; + } + return out ? JSON.parse(out) as DbRow[] : []; +} + +let cached: { stamp: number; items: ZItem[] } | undefined; + +export async function dbItems(signal?: AbortSignal): Promise<ZItem[]> { + const path = await copyDb(); + const stamp = await mtime(path); + if (cached?.stamp === stamp) return cached.items; + let rows: DbRow[]; + try { + rows = await sqlite(path, queryFor(true), signal); + } catch (e) { + if (!(e instanceof Error) || !/no such table/i.test(e.message)) throw e; + rows = await sqlite(path, queryFor(false), signal); + } + const items = rows.map((r) => fromDb(r, STORAGE_DIR)); + cached = { stamp, items }; + return items; +} + +/** Results for what is typed, and where they came from. */ +export async function find( + query: string, + signal?: AbortSignal, +): Promise<{ items: ZItem[]; kind: Kind; total?: number }> { + const viaApi = await apiSearch(query, signal); + if (viaApi) return { items: viaApi, kind: "api" }; + const all = await dbItems(signal); + return { items: search(all, query, 50), kind: "db", total: all.length }; +} diff --git a/extensions/zotero/zotero.ts b/extensions/zotero/zotero.ts new file mode 100644 index 0000000..56657d5 --- /dev/null +++ b/extensions/zotero/zotero.ts @@ -0,0 +1,248 @@ +// Zotero items as launcher items. + +import { + type Action, + action, + clipboard, + close, + copy, + Icons, + type Item, + meta, + type Metadata, + open, + paste, +} from "@backslash/api"; +import { + apaCitation, + bibtex, + citationKey, + creatorSummary, + displayDate, + doiUrl, + fullName, + inTextCitation, + mainPdf, + openPdfUrl, + publication, + selectUrl, + typeName, + year, + type ZItem, +} from "./lib.ts"; +import { apiBibtex, type Kind, SetupError } from "./source.ts"; + +const TYPE_ICONS: Record<string, string> = { + book: Icons.book, + bookSection: Icons.book, + thesis: Icons.archive, + webpage: Icons.globe, + blogPost: Icons.rss, + forumPost: Icons.comment, + videoRecording: Icons.video, + film: Icons.film, + audioRecording: Icons.music, + podcast: Icons.mic, + radioBroadcast: Icons.radio, + tvBroadcast: Icons.monitor, + computerProgram: Icons.code, + dataset: Icons.database, + presentation: Icons.monitor, + map: Icons.globe, + artwork: Icons.image, + email: Icons.mail, + letter: Icons.mail, + patent: Icons.shield, + statute: Icons.shield, + case: Icons.shield, + conferencePaper: Icons.person, + report: Icons.file, + preprint: Icons.file, +}; + +const pages = (item: ZItem) => { + const f = item.fields; + return [f.volume ? `Vol. ${f.volume}` : "", f.issue ? `No. ${f.issue}` : "", f.pages ? `pp. ${f.pages}` : ""] + .filter(Boolean) + .join(", "); +}; + +function itemMetadata(item: ZItem): Metadata[] { + const f = item.fields; + const lines: Metadata[] = [ + meta.text("Type", typeName(item.itemType), { icon: TYPE_ICONS[item.itemType] ?? Icons.file }), + ]; + for (const c of item.creators.slice(0, 6)) { + lines.push(meta.person(c.creatorType === "author" ? "Author" : typeName(c.creatorType), fullName(c))); + } + if (item.creators.length > 6) lines.push(meta.text("", `and ${item.creators.length - 6} more`)); + if (f.date) lines.push(meta.text("Date", displayDate(f.date)!, { icon: Icons.calendar })); + const pub = publication(item); + if (pub) lines.push(meta.text("Published in", pub, { icon: Icons.book })); + if (pages(item)) lines.push(meta.text("Volume", pages(item))); + if (f.publisher && f.publisher !== pub) { + lines.push(meta.text("Publisher", [f.publisher, f.place].filter(Boolean).join(", "))); + } + if (f.DOI) lines.push(meta.link("DOI", f.DOI, doiUrl(f.DOI)!)); + else if (f.url) lines.push(meta.link("URL", f.url.replace(/^https?:\/\/(www\.)?/, "").slice(0, 50), f.url)); + if (f.ISBN) lines.push(meta.text("ISBN", f.ISBN)); + if (item.tags.length) { + lines.push(meta.tags("Tags", item.tags.slice(0, 10).map((t) => ({ text: t, color: "orange" as const })))); + } + lines.push(meta.separator()); + if (item.attachments.length) { + lines.push(meta.tags( + "Attachments", + item.attachments.slice(0, 4).map((a) => ({ + text: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title, + color: a.contentType === "application/pdf" ? "red" as const : undefined, + })), + )); + } + lines.push(meta.text("Citation key", citationKey(item), { icon: Icons.braces })); + if (item.groupId) lines.push(meta.text("Library", `Group ${item.groupId}`, { icon: Icons.person })); + if (item.dateAdded) { + lines.push( + meta.text("Added", new Date(item.dateAdded).toLocaleDateString("en", { dateStyle: "medium" }), { + icon: Icons.plus, + }), + ); + } + return lines; +} + +function itemMarkdown(item: ZItem): string { + const abstract = item.fields.abstractNote?.trim(); + const short = abstract && abstract.length > 1200 ? `${abstract.slice(0, 1200).replace(/\s+\S*$/, "")}…` : abstract; + return [ + `### ${(item.title || "Untitled").replace(/[#*_`[\]]/g, "\\$&")}`, + "", + `> ${apaCitation(item).replace(/[*_`[\]]/g, "\\$&")}`, + ...(short ? ["", "**Abstract**", "", short] : []), + ].join("\n"); +} + +function itemActions(item: ZItem, kind: Kind): Action[] { + const pdf = mainPdf(item); + const f = item.fields; + const link = doiUrl(f.DOI) ?? f.url; + const citation = apaCitation(item); + return [ + open(selectUrl(item), "Show in Zotero", { icon: Icons.external }), + ...(pdf + ? [ + open(openPdfUrl(item, pdf), "Open PDF in Zotero", { icon: Icons.file, shortcut: "<Ctrl>o" }), + ...(pdf.path + ? [open(pdf.path, "Open PDF in Default App", { icon: Icons.file, shortcut: "<Ctrl><Shift>o" })] + : []), + ] + : []), + copy(citation, "Copy Citation", { shortcut: "<Ctrl><Shift>c", section: "Cite", icon: Icons.copy }), + paste(citation, "Paste Citation", { shortcut: "<Ctrl><Shift>v", section: "Cite" }), + copy(inTextCitation(item), "Copy In-Text Citation", { section: "Cite" }), + action("Copy BibTeX", async () => { + // Zotero's own export when the local API is on; the built one otherwise. + const text = (kind === "api" ? await apiBibtex(item) : undefined) ?? bibtex(item); + return close(await copyText(text, "BibTeX copied")); + }, { icon: Icons.braces, shortcut: "<Ctrl>b", section: "Cite" }), + copy(citationKey(item), "Copy Citation Key", { section: "Cite" }), + ...(link + ? [ + open(link, f.DOI ? "Open DOI" : "Open URL", { icon: Icons.globe, section: "Links" }), + copy(link, f.DOI ? "Copy DOI Link" : "Copy URL", { icon: Icons.link, section: "Links" }), + ] + : []), + copy(selectUrl(item), "Copy Zotero Link", { icon: Icons.link, section: "Links" }), + copy(item.title, "Copy Title", { section: "Links" }), + ...item.attachments.filter((a) => a !== pdf && a.path).slice(0, 3).map((a) => + open(a.path!, `Open “${a.title.length > 30 ? `${a.title.slice(0, 29)}…` : a.title}”`, { + icon: Icons.file, + section: "Attachments", + }) + ), + ]; +} + +async function copyText(text: string, message: string): Promise<string> { + await clipboard.write(text); + return message; +} + +export function zoteroItem(item: ZItem, kind: Kind): Item { + const pdf = mainPdf(item); + const y = year(item); + const who = creatorSummary(item); + return { + id: `${item.groupId ?? 0}/${item.key}`, + title: item.title || "Untitled", + subtitle: [who, publication(item)].filter(Boolean).join(" · ") || undefined, + icon: { source: TYPE_ICONS[item.itemType] ?? Icons.file, tint: item.itemType === "book" ? "orange" : "blue" }, + accessories: [ + ...item.tags.slice(0, 1).map((t) => ({ tag: t, color: "orange" as const })), + ...(pdf ? [{ icon: { source: Icons.file, tint: "red" as const }, tooltip: "PDF attached" }] : []), + ...(y ? [{ text: y, tooltip: displayDate(item.fields.date) }] : []), + ], + detail: { markdown: itemMarkdown(item), metadata: itemMetadata(item) }, + actions: itemActions(item, kind), + }; +} + +export async function setupItems(e: SetupError): Promise<Item[]> { + const osRelease = await Deno.readTextFile("/etc/os-release").catch(() => ""); + const enable = [ + "**Turn on Zotero's local API** (Zotero 7): **Edit → Settings → Advanced → Miscellaneous →", + "Allow other applications on this computer to communicate with Zotero**. Backslash then searches", + "through Zotero while it runs.", + ]; + if (e.reason === "no-sqlite") { + return [{ + id: "setup", + title: "Turn on Zotero's local API, or install sqlite3", + subtitle: "Zotero 7 → Settings → Advanced → Allow other applications…", + icon: { source: Icons.plug, tint: "orange" }, + detail: { + markdown: [ + "## Connect Zotero", + "", + ...enable, + "", + "**Or install sqlite3** to read your library even when Zotero is closed:", + "", + `\`${sqliteInstall(osRelease)}\``, + ].join("\n"), + }, + actions: [ + copy(sqliteInstall(osRelease), "Copy Install Command"), + open("zotero://select/library", "Open Zotero", { icon: Icons.external }), + ], + }]; + } + return [{ + id: "setup", + title: "No Zotero library found", + subtitle: "Backslash reads ~/Zotero, Zotero's default data directory", + icon: { source: Icons.book, tint: "orange" }, + detail: { + markdown: [ + "## No Zotero library", + "", + "Zotero keeps its library in **~/Zotero** unless you moved it (Settings → Advanced → Files and Folders).", + "Backslash reads that folder only.", + "", + ...enable, + ].join("\n"), + }, + actions: [open("https://www.zotero.org/download/", "Download Zotero", { icon: Icons.download })], + }]; +} + +/** `ID` and `ID_LIKE` from /etc/os-release, to pick the package manager. */ +export function sqliteInstall(osRelease: string): string { + const get = (k: string) => new RegExp(`^${k}=(.*)$`, "m").exec(osRelease)?.[1]?.replace(/["']/g, "") ?? ""; + const ids = `${get("ID")} ${get("ID_LIKE")}`.toLowerCase().split(/\s+/); + if (ids.some((i) => ["arch", "manjaro", "endeavouros"].includes(i))) return "sudo pacman -S sqlite"; + if (ids.some((i) => ["fedora", "rhel", "centos"].includes(i))) return "sudo dnf install sqlite"; + if (ids.some((i) => ["opensuse", "suse", "opensuse-tumbleweed"].includes(i))) return "sudo zypper install sqlite3"; + if (ids.includes("alpine")) return "sudo apk add sqlite"; + return "sudo apt install sqlite3"; +} diff --git a/registry/anki.toml b/registry/anki.toml new file mode 100644 index 0000000..cfda5b7 --- /dev/null +++ b/registry/anki.toml @@ -0,0 +1,14 @@ +# registry/anki.toml +id = "anki" +title = "Anki" +description = "Your Anki decks with today's new, learning and review counts, quick card creation and sync, through AnkiConnect" +author = "Backslash" +icon = "bs-layers" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/anki" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:aa23cc6c2e159a1f35b72e0c377193b79d6f2eafe96c93d0f0e5397a6af1aef4" +permissions = ["Access 127.0.0.1:8765", "Run systemd-run", "Read /etc/os-release", "Read /usr/bin/anki", "Read /usr/local/bin/anki", "Read ~/.local/bin/anki", "Read /var/lib/flatpak/exports/bin/net.ankiweb.Anki", "Read ~/.local/share/flatpak/exports/bin/net.ankiweb.Anki", "Read /snap/bin/anki", "Read the environment variable HOME"] +commands = ["Anki Decks", "Add Anki Card", "Quick Add Anki Card", "Sync Anki"] + diff --git a/registry/asana.toml b/registry/asana.toml new file mode 100644 index 0000000..6d50285 --- /dev/null +++ b/registry/asana.toml @@ -0,0 +1,14 @@ +# registry/asana.toml +id = "asana" +title = "Asana" +description = "Your Asana tasks by due date and your projects; complete, reschedule and create tasks" +author = "Backslash" +icon = "bs-circle-check" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/asana" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:3f32bb44cb4d8abfdf2c945c7d3ff382e2f97479b0143fc886f9c5183e0dfd57" +permissions = ["Access app.asana.com"] +commands = ["My Asana Tasks", "Asana Projects", "Create Asana Task"] + diff --git a/registry/aws.toml b/registry/aws.toml new file mode 100644 index 0000000..2c08fdb --- /dev/null +++ b/registry/aws.toml @@ -0,0 +1,14 @@ +# registry/aws.toml +id = "aws" +title = "AWS" +description = "Switch AWS profiles, start and stop EC2 instances, open SSM sessions, browse S3 buckets and tail CloudWatch logs with the aws CLI" +author = "Backslash" +icon = "bs-cloud" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/aws" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:2e890cf20e27924d87646c31e1249472d957fdcef6bde23ec01f6255a1bd4624" +permissions = ["Run aws", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read ~/.aws/config", "Read ~/.aws/credentials", "Read /etc/os-release", "Read the environment variable HOME", "Read the environment variable AWS_PROFILE", "Read the environment variable AWS_REGION", "Read the environment variable AWS_DEFAULT_REGION"] +commands = ["AWS Profiles", "EC2 Instances", "S3 Buckets", "CloudWatch Log Groups"] + diff --git a/registry/books.toml b/registry/books.toml new file mode 100644 index 0000000..3cec971 --- /dev/null +++ b/registry/books.toml @@ -0,0 +1,14 @@ +# registry/books.toml +id = "books" +title = "Books" +description = "Search Open Library for books with covers, authors, editions and subjects, copy ISBNs and citations, and keep a reading list" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/books" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:3b5963a3c3e51f15d1dbf7f3d983441db4264b78cf5a7bcaead81f303112df83" +permissions = ["Access openlibrary.org", "Access covers.openlibrary.org"] +commands = ["Search Books", "Reading List"] + diff --git a/registry/clickup.toml b/registry/clickup.toml new file mode 100644 index 0000000..1dde94d --- /dev/null +++ b/registry/clickup.toml @@ -0,0 +1,14 @@ +# registry/clickup.toml +id = "clickup" +title = "ClickUp" +description = "Your ClickUp tasks across workspaces with their status, priority and due date; change status and create tasks" +author = "Backslash" +icon = "bs-circle-half" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/clickup" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:e00bff13dbc66c90913ae3a0ebe002675e2f3c34b7f3465fe9eb5e34efdd8c58" +permissions = ["Access api.clickup.com", "Access attachments.clickup.com"] +commands = ["My ClickUp Tasks", "Create ClickUp Task"] + diff --git a/registry/cloudflare.toml b/registry/cloudflare.toml new file mode 100644 index 0000000..3133d91 --- /dev/null +++ b/registry/cloudflare.toml @@ -0,0 +1,14 @@ +# registry/cloudflare.toml +id = "cloudflare" +title = "Cloudflare" +description = "Manage Cloudflare zones and DNS records, purge the cache and follow Pages deployments" +author = "Backslash" +icon = "bs-cloud" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/cloudflare" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:9dc158729fcc0176854fb436090b839cbe3584051eca54b772b96f82ce66d0e2" +permissions = ["Access api.cloudflare.com", "Read the environment variable CLOUDFLARE_API_TOKEN"] +commands = ["Cloudflare Zones", "Cloudflare DNS Records", "Cloudflare Pages"] + diff --git a/registry/codeberg.toml b/registry/codeberg.toml new file mode 100644 index 0000000..ea6e407 --- /dev/null +++ b/registry/codeberg.toml @@ -0,0 +1,14 @@ +# registry/codeberg.toml +id = "codeberg" +title = "Codeberg" +description = "Your Codeberg pull requests with checks, issues, repositories and notifications" +author = "Backslash" +icon = "bs-repo" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/codeberg" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:e20f4e996b386cdd2be4419d2eb66802093b8c4852f8b76b969ec113f40ea721" +permissions = ["Access codeberg.org"] +commands = ["My Codeberg Pull Requests", "My Codeberg Issues", "Search Codeberg Repositories", "Codeberg Notifications"] + diff --git a/registry/countdowns.toml b/registry/countdowns.toml new file mode 100644 index 0000000..18558dc --- /dev/null +++ b/registry/countdowns.toml @@ -0,0 +1,14 @@ +# registry/countdowns.toml +id = "countdowns" +title = "Countdowns" +description = "Count down to vacations, launches and birthdays, and pin the next one to the root search" +author = "Backslash" +icon = "bs-hourglass" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/countdowns" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:448efb728c224771d9a2c408f3f30787fb72e65373c27446815576f98fdd39b5" +permissions = [] +commands = ["Countdowns", "Add Countdown"] + diff --git a/registry/cron.toml b/registry/cron.toml new file mode 100644 index 0000000..7a3642c --- /dev/null +++ b/registry/cron.toml @@ -0,0 +1,14 @@ +# registry/cron.toml +id = "cron" +title = "Cron Helper" +description = "Explain cron and systemd calendar expressions, see their next runs and convert between them" +author = "Backslash" +icon = "bs-clock" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/cron" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:d86c10c32556ef500ddc2958d418ad8dd331f642419943412d79e7cc7e5338c3" +permissions = [] +commands = ["Cron Helper"] + diff --git a/registry/crypto.toml b/registry/crypto.toml new file mode 100644 index 0000000..3109a8e --- /dev/null +++ b/registry/crypto.toml @@ -0,0 +1,14 @@ +# registry/crypto.toml +id = "crypto" +title = "Crypto" +description = "Cryptocurrency prices, 24 h and 7 day changes, market caps and a watchlist from CoinGecko, and the value of your holdings" +author = "Backslash" +icon = "bs-coin" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/crypto" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:ebbb89566c88a7c1f84ea69d91d322c312d707bd3e1562230b989b2e90e55ded" +permissions = ["Access api.coingecko.com", "Access coin-images.coingecko.com", "Access assets.coingecko.com"] +commands = ["Crypto Prices", "Crypto Portfolio"] + diff --git a/registry/databases.toml b/registry/databases.toml new file mode 100644 index 0000000..021f989 --- /dev/null +++ b/registry/databases.toml @@ -0,0 +1,14 @@ +# registry/databases.toml +id = "databases" +title = "Databases" +description = "Open your PostgreSQL, MySQL, SQLite and Redis connections in a terminal, check that they answer and copy their URLs" +author = "Backslash" +icon = "bs-database" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/databases" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:64bddfcf0c619965ab8dec359e2b89b8bfd2ce47286ee75fba3b841b261ea9b4" +permissions = ["Run psql", "Run pgcli", "Run pg_isready", "Run mysql", "Run mariadb", "Run mycli", "Run mysqladmin", "Run sqlite3", "Run litecli", "Run redis-cli", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read ~/.pgpass", "Read ~/.pg_service.conf", "Read ~/.my.cnf", "Read /etc/os-release", "Read the environment variable HOME"] +commands = ["Database Connections", "Add Database Connection"] + diff --git a/registry/default-apps.toml b/registry/default-apps.toml new file mode 100644 index 0000000..4bea2e7 --- /dev/null +++ b/registry/default-apps.toml @@ -0,0 +1,14 @@ +# registry/default-apps.toml +id = "default-apps" +title = "Default Apps" +description = "See and change your default browser, mail app, file manager, viewers, players, editor and terminal" +author = "Backslash" +icon = "bs-apps" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/default-apps" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:ebd59076e1ba923be59e4ceca6c8b9fc8d383bd1349210257b4d6045b1661298" +permissions = ["Run xdg-mime", "Run xdg-settings", "Read ~/.local/share/applications", "Read ~/.local/share/flatpak/exports/share/applications", "Read /usr/share/applications", "Read /usr/local/share/applications", "Read /var/lib/flatpak/exports/share/applications", "Read /var/lib/snapd/desktop/applications", "Read ~/.nix-profile/share/applications", "Read /run/current-system/sw/share/applications", "Read ~/.config/xdg-terminals.list", "Read /etc/os-release", "Write ~/.config/xdg-terminals.list", "Read the environment variable HOME", "Read the environment variable XDG_DATA_HOME", "Read the environment variable XDG_DATA_DIRS", "Read the environment variable TERMINAL"] +commands = ["Default Apps"] + diff --git a/registry/dev-reference.toml b/registry/dev-reference.toml new file mode 100644 index 0000000..e888b94 --- /dev/null +++ b/registry/dev-reference.toml @@ -0,0 +1,14 @@ +# registry/dev-reference.toml +id = "dev-reference" +title = "Dev Reference" +description = "HTTP status codes, MIME types, ports, ASCII, HTML entities, key codes, signals, exit codes and a chmod calculator" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/dev-reference" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:33ec13a1e82c9d58c4d2722a475499f793dec1ee6310e87e4b1302089916758d" +permissions = [] +commands = ["Dev Reference"] + diff --git a/registry/disk-usage.toml b/registry/disk-usage.toml new file mode 100644 index 0000000..3743663 --- /dev/null +++ b/registry/disk-usage.toml @@ -0,0 +1,14 @@ +# registry/disk-usage.toml +id = "disk-usage" +title = "Disk Usage" +description = "Find what fills your disk: biggest folders and files, caches, trash, logs and containers, with cleanups" +author = "Backslash" +icon = "bs-drive" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/disk-usage" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:831621f95a0a3413707658af595997f915bdf92bb33c4a59a4b922dd5ff6cddb" +permissions = ["Run du", "Run find", "Run df", "Run journalctl", "Run docker", "Run podman", "Run gio", "Run flatpak", "Run gdbus", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read ~", "Read /etc/os-release", "Write ~/.cache", "Read the environment variable HOME"] +commands = ["Disk Usage"] + diff --git a/registry/downloads.toml b/registry/downloads.toml new file mode 100644 index 0000000..991bc3d --- /dev/null +++ b/registry/downloads.toml @@ -0,0 +1,14 @@ +# registry/downloads.toml +id = "downloads" +title = "Downloads" +description = "Recent downloads with previews and where they came from; open, copy, move, rename or trash them" +author = "Backslash" +icon = "bs-download" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/downloads" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:a502fbb08ab487b4ae9eee7ddf4139c2e371a3ffebbc8bbf3d8bd33a10b5cea0" +permissions = ["Run getfattr", "Run gio", "Run mv", "Run mkdir", "Run gdbus", "Run wl-copy", "Run xclip", "Read ~", "Read the environment variable HOME", "Read the environment variable WAYLAND_DISPLAY"] +commands = ["Downloads"] + diff --git a/registry/flatpak.toml b/registry/flatpak.toml new file mode 100644 index 0000000..ea909da --- /dev/null +++ b/registry/flatpak.toml @@ -0,0 +1,14 @@ +# registry/flatpak.toml +id = "flatpak" +title = "Flatpak Apps" +description = "Browse installed Flatpak apps and runtimes, see their size and permissions, run or uninstall them" +author = "Backslash" +icon = "bs-package" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/flatpak" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:68a1e2aa3496b7740647380303c9dd2a1f3a3a0f0b13d02e4408ba71cfdaf338" +permissions = ["Run flatpak", "Read /var/lib/flatpak/exports/share/icons/hicolor", "Read ~/.local/share/flatpak/exports/share/icons/hicolor", "Read ~/.var/app", "Read /etc/os-release", "Read the environment variable HOME"] +commands = ["Flatpak Apps"] + diff --git a/registry/fonts.toml b/registry/fonts.toml new file mode 100644 index 0000000..0aeb214 --- /dev/null +++ b/registry/fonts.toml @@ -0,0 +1,14 @@ +# registry/fonts.toml +id = "fonts" +title = "Fonts" +description = "Browse installed font families with previews, copy their CSS and install new ones" +author = "Backslash" +icon = "bs-font" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/fonts" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:7eec4799f8b531c5aa6a5fd5000782c95a1db808470083b912bcaa78170de3a6" +permissions = ["Run fc-list", "Run fc-cache", "Run pango-view", "Run convert", "Run cp", "Read ~/.cache/backslash/fonts", "Read ~/.local/share/fonts", "Write ~/.cache/backslash/fonts", "Write ~/.local/share/fonts", "Read the environment variable HOME"] +commands = ["Fonts", "Install Font"] + diff --git a/registry/git-repos.toml b/registry/git-repos.toml new file mode 100644 index 0000000..2ea0d07 --- /dev/null +++ b/registry/git-repos.toml @@ -0,0 +1,14 @@ +# registry/git-repos.toml +id = "git-repos" +title = "Git Repos" +description = "Your local git repositories with branch, changes, ahead/behind and last commit, plus fetch, pull and stash" +author = "Backslash" +icon = "bs-branch" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/git-repos" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:bdc9ab2ef322c73791d966d0ae14cc4cfdbbffeca30af36ad7f128593ea0fad6" +permissions = ["Run git", "Run find", "Run code", "Run codium", "Run cursor", "Run zed", "Run zeditor", "Run subl", "Run idea", "Run gnome-text-editor", "Run kate", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read /etc/os-release", "Read the environment variable HOME"] +commands = ["Git Repositories"] + diff --git a/registry/google-cloud.toml b/registry/google-cloud.toml new file mode 100644 index 0000000..578ca50 --- /dev/null +++ b/registry/google-cloud.toml @@ -0,0 +1,14 @@ +# registry/google-cloud.toml +id = "google-cloud" +title = "Google Cloud" +description = "Switch gcloud projects, SSH into Compute Engine VMs and follow Cloud Run services, their URLs and logs" +author = "Backslash" +icon = "bs-cloud" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/google-cloud" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:21995ffebc40effb2917276196aee6bf799bd83c786a9c91ba46f22c8db24394" +permissions = ["Run gcloud", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm", "Read /etc/os-release"] +commands = ["Google Cloud Projects", "Compute Engine Instances", "Cloud Run Services"] + diff --git a/registry/http-client.toml b/registry/http-client.toml new file mode 100644 index 0000000..25d6576 --- /dev/null +++ b/registry/http-client.toml @@ -0,0 +1,14 @@ +# registry/http-client.toml +id = "http-client" +title = "HTTP Client" +description = "Send HTTP requests from the launcher and read the status, timing, headers and formatted body" +author = "Backslash" +icon = "bs-globe" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/http-client" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:c1f9e0d3ea0a419ce3592a4c43b62d1b8dbe00f1f684b8fd039b92ef3bdae07d" +permissions = ["Access any website"] +commands = ["Send HTTP Request", "Saved HTTP Requests"] + diff --git a/registry/joplin.toml b/registry/joplin.toml new file mode 100644 index 0000000..4c06c60 --- /dev/null +++ b/registry/joplin.toml @@ -0,0 +1,14 @@ +# registry/joplin.toml +id = "joplin" +title = "Joplin" +description = "Search your Joplin notes by notebook, preview them with their tags, create notes and copy links" +author = "Backslash" +icon = "bs-note" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/joplin" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:c16a0059bfdd54d85a2b6c8d7521bd6536da3ac2486a87add45d687a2fce9e9d" +permissions = ["Access 127.0.0.1:41184"] +commands = ["Search Joplin Notes", "New Joplin Note"] + diff --git a/registry/keyboard-layouts.toml b/registry/keyboard-layouts.toml new file mode 100644 index 0000000..719e546 --- /dev/null +++ b/registry/keyboard-layouts.toml @@ -0,0 +1,14 @@ +# registry/keyboard-layouts.toml +id = "keyboard-layouts" +title = "Keyboard Layouts" +description = "See the current keyboard layout and switch between layouts and input methods" +author = "Backslash" +icon = "bs-keyboard" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/keyboard-layouts" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:9a3e73491995678ce3122e13ebfe274f5280784abd74f83ca5ee93ab05521238" +permissions = ["Run gsettings", "Run gdbus", "Run hyprctl", "Run swaymsg", "Run setxkbmap", "Run xkb-switch", "Run ibus", "Run fcitx5-remote", "Read /usr/share/X11/xkb/rules/evdev.lst", "Read /usr/share/X11/xkb/rules/base.lst", "Read ~/.config/fcitx5/profile", "Read /etc/os-release", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK", "Read the environment variable HOME"] +commands = ["Keyboard Layouts", "Next Keyboard Layout"] + diff --git a/registry/logseq.toml b/registry/logseq.toml new file mode 100644 index 0000000..9bc126c --- /dev/null +++ b/registry/logseq.toml @@ -0,0 +1,14 @@ +# registry/logseq.toml +id = "logseq" +title = "Logseq" +description = "Search your Logseq pages and blocks, preview them with properties and backlinks, open today's journal and capture blocks" +author = "Backslash" +icon = "bs-layers" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/logseq" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:6e3bfed2308bd71d85438ca8cff029cf78a5825541ac9d67d9c385755120a85a" +permissions = ["Read ~", "Write ~/Logseq", "Write ~/logseq", "Write ~/Documents/Logseq", "Write ~/Documents/logseq", "Read the environment variable HOME"] +commands = ["Search Logseq", "Open Logseq Journal", "Capture to Logseq", "Logseq Graphs"] + diff --git a/registry/movies.toml b/registry/movies.toml new file mode 100644 index 0000000..fdd53d7 --- /dev/null +++ b/registry/movies.toml @@ -0,0 +1,14 @@ +# registry/movies.toml +id = "movies" +title = "Movies & TV" +description = "Search movies and TV shows on TMDB with posters, ratings, cast, trailers and where to stream them, and keep a watchlist" +author = "Backslash" +icon = "bs-film" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/movies" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:cb06cbd582be8624fc2a680e667302f3835b0faa4bf2118f1d7e52e61f8a1b96" +permissions = ["Access api.themoviedb.org", "Access image.tmdb.org"] +commands = ["Search Movies & TV"] + diff --git a/registry/mpd.toml b/registry/mpd.toml new file mode 100644 index 0000000..30f497a --- /dev/null +++ b/registry/mpd.toml @@ -0,0 +1,14 @@ +# registry/mpd.toml +id = "mpd" +title = "MPD Music" +description = "Control the Music Player Daemon: now playing with covers, queue, library search and playlists" +author = "Backslash" +icon = "bs-music" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/mpd" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:95d05deef34dcbb4022a28f02c045eb8d8d2f9653ebb95af4172fba0465dd1c9" +permissions = ["Access localhost:6600", "Access 127.0.0.1:6600", "Run systemctl", "Read ~/.cache/backslash/mpd", "Read /etc/os-release", "Write ~/.cache/backslash/mpd", "Read the environment variable HOME"] +commands = ["MPD Music", "Search Music Library", "MPD Playlists", "MPD Play / Pause", "MPD Next Song", "MPD Previous Song"] + diff --git a/registry/nerd-fonts.toml b/registry/nerd-fonts.toml new file mode 100644 index 0000000..5e5bd57 --- /dev/null +++ b/registry/nerd-fonts.toml @@ -0,0 +1,14 @@ +# registry/nerd-fonts.toml +id = "nerd-fonts" +title = "Nerd Font Icons" +description = "Search every Nerd Font glyph by name or code point and copy the glyph, its class, code point or HTML entity" +author = "Backslash" +icon = "bs-font" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/nerd-fonts" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:e4d2fde2777cec36f6468547dc77f0609ebc007e6d671b92d35276be490d9ba3" +permissions = ["Access raw.githubusercontent.com"] +commands = ["Search Nerd Font Icons"] + diff --git a/registry/network-tools.toml b/registry/network-tools.toml new file mode 100644 index 0000000..fefeae6 --- /dev/null +++ b/registry/network-tools.toml @@ -0,0 +1,14 @@ +# registry/network-tools.toml +id = "network-tools" +title = "Network Tools" +description = "Public IP and location, local interfaces, DNS lookup, whois, ping and a speed test" +author = "Backslash" +icon = "bs-globe" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/network-tools" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:748082cefa09338dea6f62b9b21cb9add26b690c0c3f154403da881b5de44002" +permissions = ["Access api.ipify.org", "Access api6.ipify.org", "Access ipinfo.io", "Access cloudflare-dns.com", "Access speed.cloudflare.com", "Run ip", "Run ping", "Run whois", "Run dig", "Read /etc/os-release"] +commands = ["Network Tools", "Public IP Address", "Copy Public IP", "Network Interfaces", "DNS Lookup", "Ping", "Whois", "Internet Speed Test"] + diff --git a/registry/ollama.toml b/registry/ollama.toml new file mode 100644 index 0000000..f11bffd --- /dev/null +++ b/registry/ollama.toml @@ -0,0 +1,14 @@ +# registry/ollama.toml +id = "ollama" +title = "Ollama Models" +description = "See the local Ollama models and the ones loaded in memory, pull new ones with live progress and delete them" +author = "Backslash" +icon = "bs-sparkle" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/ollama" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:5f121efed2641f377a9b66ecb4497ae8111f0d61c88af89c9e4202c5697927c5" +permissions = ["Access 127.0.0.1:11434", "Run ollama", "Run systemd-run", "Read /etc/os-release"] +commands = ["Ollama Models", "Pull Ollama Model"] + diff --git a/registry/package-registries.toml b/registry/package-registries.toml new file mode 100644 index 0000000..2b35688 --- /dev/null +++ b/registry/package-registries.toml @@ -0,0 +1,14 @@ +# registry/package-registries.toml +id = "package-registries" +title = "Package Registries" +description = "Search crates.io, PyPI, Go modules, RubyGems, Packagist, Maven Central, Hex, pub.dev and NuGet" +author = "Backslash" +icon = "bs-package" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/package-registries" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:ff20e9dfcf3b7f9cdd34c6b7c561026bd490bb53c6a44dd71bb96ed9faefbdc0" +permissions = ["Access crates.io", "Access pypi.org", "Access proxy.golang.org", "Access rubygems.org", "Access packagist.org", "Access repo.packagist.org", "Access search.maven.org", "Access hex.pm", "Access pub.dev", "Access azuresearch-usnc.nuget.org"] +commands = ["Search Package Registries"] + diff --git a/registry/printers.toml b/registry/printers.toml new file mode 100644 index 0000000..0079fba --- /dev/null +++ b/registry/printers.toml @@ -0,0 +1,14 @@ +# registry/printers.toml +id = "printers" +title = "Printers" +description = "See your printers and their queues, cancel jobs, pause printers and print files" +author = "Backslash" +icon = "bs-printer" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/printers" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:e1f4ae41261d97959897a786e246af81637d14cfd9fc8f281347426c2fb312d6" +permissions = ["Run lpstat", "Run lpq", "Run lp", "Run cancel", "Run lpoptions", "Run cupsenable", "Run cupsdisable", "Run cupsaccept", "Run gnome-control-center", "Run systemsettings", "Run system-config-printer", "Read /etc/os-release", "Read the environment variable HOME", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["Printers"] + diff --git a/registry/privacy.toml b/registry/privacy.toml new file mode 100644 index 0000000..d7a6c9b --- /dev/null +++ b/registry/privacy.toml @@ -0,0 +1,14 @@ +# registry/privacy.toml +id = "privacy" +title = "Privacy" +description = "See which apps use your camera, microphone, screen and location, and stop them" +author = "Backslash" +icon = "bs-shield" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/privacy" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:9fcd98af6aab77b9f454c6b85b247ef9c4f910b7ab918e3910e0066c32288179" +permissions = ["Run pw-dump", "Run pactl", "Run find", "Run fuser", "Run ps", "Run kill", "Run busctl", "Run gnome-control-center", "Run systemsettings", "Run pavucontrol", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["Privacy"] + diff --git a/registry/qr-codes.toml b/registry/qr-codes.toml new file mode 100644 index 0000000..96898d1 --- /dev/null +++ b/registry/qr-codes.toml @@ -0,0 +1,14 @@ +# registry/qr-codes.toml +id = "qr-codes" +title = "QR Codes" +description = "Turn text, links and Wi-Fi networks into QR codes, and scan the ones on your screen" +author = "Backslash" +icon = "bs-qr" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/qr-codes" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:4fa12085667c123b5aeda606a802b5761ee11e447f1831e726d63bbfca75ade0" +permissions = ["Run systemd-run", "Run zbarimg", "Run grim", "Run slurp", "Run gnome-screenshot", "Run spectacle", "Run maim", "Run scrot", "Run import", "Run wl-copy", "Run wl-paste", "Run xclip", "Read ~/.cache/backslash/qr", "Read ~/Pictures", "Read ~/Images", "Read ~/.config/user-dirs.dirs", "Read /etc/os-release", "Write ~/.cache/backslash/qr", "Write ~/Pictures", "Write ~/Images", "Read the environment variable HOME", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK", "Read the clipboard"] +commands = ["QR Codes", "QR Code from Clipboard", "Scan QR Code on Screen", "Scan QR Code in Clipboard Image"] + diff --git a/registry/radio.toml b/registry/radio.toml new file mode 100644 index 0000000..052e081 --- /dev/null +++ b/registry/radio.toml @@ -0,0 +1,14 @@ +# registry/radio.toml +id = "radio" +title = "Internet Radio" +description = "Search 50,000 internet radio stations by name, genre or country and play them in the background with mpv, ffplay or VLC" +author = "Backslash" +icon = "bs-radio" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/radio" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:2a2cad0188df48ff3fda28aca41ac67d0ca75a454f4397ab40c3534b0aed2019" +permissions = ["Access de1.api.radio-browser.info", "Access de2.api.radio-browser.info", "Access fi1.api.radio-browser.info", "Access all.api.radio-browser.info", "Run systemd-run", "Run systemctl", "Run mpv", "Run ffplay", "Run cvlc", "Read ~/.cache/backslash/radio", "Read /etc/os-release", "Write ~/.cache/backslash/radio", "Read the environment variable HOME"] +commands = ["Internet Radio", "Stop Radio"] + diff --git a/registry/raindrop.toml b/registry/raindrop.toml new file mode 100644 index 0000000..3f0b3cc --- /dev/null +++ b/registry/raindrop.toml @@ -0,0 +1,14 @@ +# registry/raindrop.toml +id = "raindrop" +title = "Raindrop.io" +description = "Search your Raindrop.io bookmarks with their covers, tags and collection; save links, favorite, move and delete" +author = "Backslash" +icon = "bs-bookmark" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/raindrop" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:4fe381049933f97442ce2c3581cf4349c7e064d206785878129303139bdccdc6" +permissions = ["Access api.raindrop.io", "Access rdl.ink"] +commands = ["Search Raindrop Bookmarks", "Save Link to Raindrop"] + diff --git a/registry/random.toml b/registry/random.toml new file mode 100644 index 0000000..ba39b76 --- /dev/null +++ b/registry/random.toml @@ -0,0 +1,14 @@ +# registry/random.toml +id = "random" +title = "Random" +description = "Flip a coin, roll dice, draw a number, pick or shuffle from a list, get a color or ask the Magic 8-Ball" +author = "Backslash" +icon = "bs-dice" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/random" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:4cd442a82019e00504cf85eefb23029f61acef104b31435a16179f41a81c674a" +permissions = ["Read the environment variable LANG", "Read the environment variable LC_ALL", "Read the environment variable LC_MESSAGES"] +commands = ["Random", "Flip a Coin", "Roll Dice"] + diff --git a/registry/reddit.toml b/registry/reddit.toml new file mode 100644 index 0000000..0d7cdfd --- /dev/null +++ b/registry/reddit.toml @@ -0,0 +1,14 @@ +# registry/reddit.toml +id = "reddit" +title = "Reddit" +description = "Hot, new and top posts from your subreddits with scores, comments, flairs, pictures and the top comments, without an account" +author = "Backslash" +icon = "bs-comment" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/reddit" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:3dc371a9c7b692ec28fc2cc3bed742dba952780d85ecac5a0820db829b2b1250" +permissions = ["Access www.reddit.com", "Access i.redd.it", "Access preview.redd.it", "Access external-preview.redd.it"] +commands = ["Reddit", "Reddit Subreddits"] + diff --git a/registry/regex.toml b/registry/regex.toml new file mode 100644 index 0000000..04a683c --- /dev/null +++ b/registry/regex.toml @@ -0,0 +1,14 @@ +# registry/regex.toml +id = "regex" +title = "Regex Tester" +description = "Test JavaScript regular expressions with groups, replacements, explanations and a library of common patterns" +author = "Backslash" +icon = "bs-braces" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/regex" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:501d2e6ebdaebfe0eaeec0f6269c001cfbb87126fba7a76af0839b2ab7067c02" +permissions = [] +commands = ["Regex Tester"] + diff --git a/registry/slack.toml b/registry/slack.toml new file mode 100644 index 0000000..b50208d --- /dev/null +++ b/registry/slack.toml @@ -0,0 +1,14 @@ +# registry/slack.toml +id = "slack" +title = "Slack" +description = "Set your Slack status from presets, pause notifications, jump to channels and search messages" +author = "Backslash" +icon = "bs-hash" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/slack" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:af8c6f500afaf001b5757b8567a284a1e0388f0b0d3dedf901c2e7b6cc9e2c22" +permissions = ["Access slack.com", "Access avatars.slack-edge.com", "Access secure.gravatar.com", "Access a.slack-edge.com"] +commands = ["Set Slack Status", "Clear Slack Status", "Pause Slack Notifications", "Slack Channels", "Search Slack Messages"] + diff --git a/registry/startup-apps.toml b/registry/startup-apps.toml new file mode 100644 index 0000000..36cf5ce --- /dev/null +++ b/registry/startup-apps.toml @@ -0,0 +1,14 @@ +# registry/startup-apps.toml +id = "startup-apps" +title = "Startup Apps" +description = "See and change what starts when you log in: autostart apps, systemd user services and Hyprland" +author = "Backslash" +icon = "bs-rocket" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/startup-apps" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:052c2702b1fb39f0ef06ea7247e0feb378aa4828a2653f340cb72b13fda61ecb" +permissions = ["Run systemctl", "Read ~/.config/autostart", "Read /etc/xdg/autostart", "Read ~/.config/hypr", "Write ~/.config/autostart", "Read the environment variable HOME", "Read the environment variable XDG_CURRENT_DESKTOP"] +commands = ["Startup Apps"] + diff --git a/registry/stocks.toml b/registry/stocks.toml new file mode 100644 index 0000000..b044f5f --- /dev/null +++ b/registry/stocks.toml @@ -0,0 +1,14 @@ +# registry/stocks.toml +id = "stocks" +title = "Stocks" +description = "A stock watchlist with live quotes, day ranges, company profiles and news, from Finnhub's free API" +author = "Backslash" +icon = "bs-chart-up" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/stocks" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:04b2a27255296b46af1eef10953133447af11c87d99e6ce60cb0fab15eac6cfb" +permissions = ["Access finnhub.io", "Access static2.finnhub.io"] +commands = ["Stock Watchlist"] + diff --git a/registry/system-logs.toml b/registry/system-logs.toml new file mode 100644 index 0000000..a01af54 --- /dev/null +++ b/registry/system-logs.toml @@ -0,0 +1,14 @@ +# registry/system-logs.toml +id = "system-logs" +title = "System Logs" +description = "Errors and warnings of this boot from systemd's journal, grouped by service" +author = "Backslash" +icon = "bs-activity" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/system-logs" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:3528a9909fe314a98cd0559e0ec6300cc1e5639c3f39fd3a990f1303c660ef23" +permissions = ["Run journalctl", "Run kitty", "Run alacritty", "Run wezterm", "Run foot", "Run ghostty", "Run ptyxis", "Run kgx", "Run gnome-terminal", "Run konsole", "Run xfce4-terminal", "Run tilix", "Run xterm"] +commands = ["System Logs"] + diff --git a/registry/text-to-speech.toml b/registry/text-to-speech.toml new file mode 100644 index 0000000..f810d05 --- /dev/null +++ b/registry/text-to-speech.toml @@ -0,0 +1,14 @@ +# registry/text-to-speech.toml +id = "text-to-speech" +title = "Text to Speech" +description = "Read the clipboard, the selection or any text aloud with Piper, eSpeak NG, Speech Dispatcher or Festival" +author = "Backslash" +icon = "bs-speaker" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/text-to-speech" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:e99be9ff99322eecbae9a75a41a2b78f8425e067d15d2146637bf55c0324f091" +permissions = ["Run systemd-run", "Run systemctl", "Run piper", "Run espeak-ng", "Run spd-say", "Run festival", "Run wl-paste", "Run xclip", "Run xsel", "Read ~/.cache/backslash/tts", "Read ~/.local/share/piper", "Read /etc/os-release", "Write ~/.cache/backslash/tts", "Read the environment variable HOME", "Read the environment variable XDG_RUNTIME_DIR", "Read the environment variable DBUS_SESSION_BUS_ADDRESS", "Read the environment variable PULSE_SERVER", "Read the environment variable PIPEWIRE_REMOTE", "Read the clipboard"] +commands = ["Text to Speech", "Speak Clipboard", "Speak Selected Text", "Speak Text", "Stop Speaking", "Text to Speech Voices"] + diff --git a/registry/ticktick.toml b/registry/ticktick.toml new file mode 100644 index 0000000..5f60600 --- /dev/null +++ b/registry/ticktick.toml @@ -0,0 +1,14 @@ +# registry/ticktick.toml +id = "ticktick" +title = "TickTick" +description = "Today's and upcoming TickTick tasks by project; complete, reschedule and quick-add tasks in plain language" +author = "Backslash" +icon = "bs-square-check" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/ticktick" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:bc7ddd58b5c0a837bc7f159934fbf062e27926ae98eafd621d6ddb34837c48cf" +permissions = ["Access api.ticktick.com", "Access ticktick.com"] +commands = ["TickTick Tasks", "Add TickTick Task"] + diff --git a/registry/todo-txt.toml b/registry/todo-txt.toml new file mode 100644 index 0000000..49fc3e4 --- /dev/null +++ b/registry/todo-txt.toml @@ -0,0 +1,14 @@ +# registry/todo-txt.toml +id = "todo-txt" +title = "Todo.txt" +description = "Your todo.txt tasks by due date, with priorities, +projects, @contexts, recurrence and quick add" +author = "Backslash" +icon = "bs-square-check" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/todo-txt" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:11d43842a8fe302c2e968eefd9063df3c54f271aaf91da6628c0dcf6355cec50" +permissions = ["Read ~/todo.txt", "Read ~/done.txt", "Read ~/Documents/todo.txt", "Read ~/Documents/done.txt", "Read ~/Dropbox/todo", "Read ~/.todo", "Write ~/todo.txt", "Write ~/done.txt", "Write ~/Documents/todo.txt", "Write ~/Documents/done.txt", "Write ~/Dropbox/todo", "Write ~/.todo", "Read the environment variable HOME"] +commands = ["Todo.txt Tasks", "Add Todo.txt Task"] + diff --git a/registry/trash.toml b/registry/trash.toml new file mode 100644 index 0000000..7b496ba --- /dev/null +++ b/registry/trash.toml @@ -0,0 +1,14 @@ +# registry/trash.toml +id = "trash" +title = "Trash" +description = "See what is in the trash, preview it, restore items or delete them for good" +author = "Backslash" +icon = "bs-trash" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/trash" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:f11eccadcaac3f839c75b7395c59af81f74eb27fac42f8082b2fc7df39ea47a7" +permissions = ["Run gio", "Run du", "Run mv", "Run mkdir", "Read ~", "Write ~/.local/share/Trash", "Read the environment variable HOME", "Read the environment variable XDG_DATA_HOME"] +commands = ["Trash"] + diff --git a/registry/trello.toml b/registry/trello.toml new file mode 100644 index 0000000..e418de3 --- /dev/null +++ b/registry/trello.toml @@ -0,0 +1,14 @@ +# registry/trello.toml +id = "trello" +title = "Trello" +description = "Your boards and cards with labels, due dates and members; move, complete, archive and create cards" +author = "Backslash" +icon = "bs-kanban" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/trello" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:5f0861191f1932a88cabc9daaff670c32b3d298c5deec741f1847ae29db79a8d" +permissions = ["Access api.trello.com", "Access trello-members.s3.amazonaws.com", "Access trello-backgrounds.s3.amazonaws.com"] +commands = ["Trello Boards", "My Trello Cards", "Create Trello Card"] + diff --git a/registry/wikipedia.toml b/registry/wikipedia.toml new file mode 100644 index 0000000..9219ede --- /dev/null +++ b/registry/wikipedia.toml @@ -0,0 +1,14 @@ +# registry/wikipedia.toml +id = "wikipedia" +title = "Wikipedia" +description = "Search Wikipedia in your language, read summaries and whole articles, and discover random pages" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/wikipedia" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:56bbc681bf34277b050cc3495eba11cd299e775dcc0423812b1f103e1bbc9050" +permissions = ["Access en.wikipedia.org", "Access fr.wikipedia.org", "Access de.wikipedia.org", "Access es.wikipedia.org", "Access it.wikipedia.org", "Access pt.wikipedia.org", "Access nl.wikipedia.org", "Access pl.wikipedia.org", "Access ja.wikipedia.org", "Access zh.wikipedia.org", "Access ru.wikipedia.org", "Access sv.wikipedia.org", "Access uk.wikipedia.org", "Access ar.wikipedia.org", "Access ca.wikipedia.org", "Access cs.wikipedia.org", "Access fi.wikipedia.org", "Access ko.wikipedia.org", "Access no.wikipedia.org", "Access tr.wikipedia.org", "Access vi.wikipedia.org", "Access he.wikipedia.org", "Access id.wikipedia.org", "Access fa.wikipedia.org", "Access simple.wikipedia.org", "Access *.wikipedia.org", "Access upload.wikimedia.org", "Read the environment variable LANG", "Read the environment variable LC_ALL", "Read the environment variable LC_MESSAGES"] +commands = ["Search Wikipedia", "Random Wikipedia Article", "On This Day"] + diff --git a/registry/window-manager.toml b/registry/window-manager.toml new file mode 100644 index 0000000..b314046 --- /dev/null +++ b/registry/window-manager.toml @@ -0,0 +1,14 @@ +# registry/window-manager.toml +id = "window-manager" +title = "Window Manager" +description = "Snap, maximize, center, close and move the focused window between workspaces" +author = "Backslash" +icon = "bs-window" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/window-manager" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:17588de967e17d1a6be608b2d30623bdcfd274d83f45b77fcea6aad4f1bc21ac" +permissions = ["Run systemd-run", "Run hyprctl", "Run swaymsg", "Run gdbus", "Run gsettings", "Run wmctrl", "Run xrandr", "Read /etc/os-release", "Read the environment variable XDG_SESSION_TYPE", "Read the environment variable XDG_CURRENT_DESKTOP", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable HYPRLAND_INSTANCE_SIGNATURE", "Read the environment variable SWAYSOCK", "Read the environment variable DBUS_SESSION_BUS_ADDRESS"] +commands = ["Window Manager", "Left Half", "Right Half", "Top Half", "Bottom Half", "Top Left Quarter", "Top Right Quarter", "Bottom Left Quarter", "Bottom Right Quarter", "Maximize Window", "Center Window", "Toggle Fullscreen", "Toggle Floating", "Minimize Window", "Close Window", "Move Window to Workspace", "Switch to Workspace"] + diff --git a/registry/youtube.toml b/registry/youtube.toml new file mode 100644 index 0000000..8dd821f --- /dev/null +++ b/registry/youtube.toml @@ -0,0 +1,14 @@ +# registry/youtube.toml +id = "youtube" +title = "YouTube" +description = "Search YouTube with thumbnails, views and durations, open videos in the browser or play them in mpv in the background, and browse channels" +author = "Backslash" +icon = "bs-video" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/youtube" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:f971fd9c169cd4b6c19bde16f29041cca21ece25e5e4d84e8ce67c2edbfe23dc" +permissions = ["Access www.googleapis.com", "Access i.ytimg.com", "Run systemd-run", "Run systemctl", "Run mpv", "Run yt-dlp", "Read /etc/os-release", "Read the environment variable WAYLAND_DISPLAY", "Read the environment variable DISPLAY", "Read the environment variable XAUTHORITY", "Read the environment variable XDG_RUNTIME_DIR"] +commands = ["Search YouTube"] + diff --git a/registry/zotero.toml b/registry/zotero.toml new file mode 100644 index 0000000..6b80c69 --- /dev/null +++ b/registry/zotero.toml @@ -0,0 +1,14 @@ +# registry/zotero.toml +id = "zotero" +title = "Zotero" +description = "Search your Zotero library by title, author or tag, copy APA citations and BibTeX, and open PDFs" +author = "Backslash" +icon = "bs-book" +version = "1.0.0" +repository = "https://github.com/backslash-app/plugins" +path = "extensions/zotero" +commit = "53fce9a6663b82f70a0372e627974240c926ba85" +checksum = "sha256:2db5e58ae75db33f8590dcd04493f157f5f1c680f1a7fe62141fa53cd771fb9c" +permissions = ["Access 127.0.0.1:23119", "Run sqlite3", "Read ~/Zotero", "Read ~/.cache/backslash-zotero", "Read /etc/os-release", "Write ~/.cache/backslash-zotero", "Read the environment variable HOME"] +commands = ["Search Zotero"] +