diff --git a/AGENTS.md b/AGENTS.md index 9bea723..dea20d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,8 @@ src/lib/draft.ts What the edit form edits, and the pure parsing around it src/lib/text.ts String helpers every surface shares src/lib/url.ts Small URL helpers src/lib/amazon-book.ts Amazon product HTML → ISBN → Goodreads URL. Pure. +src/lib/suggest.ts `suggestShortcuts`: visited pages → ranked keyword suggestions. Pure. +src/lib/history.ts The only `chrome.history` caller: optional-permission check, request, read src/lib/install.ts The onInstalled branch: starter pick, rule sync, welcome tab src/background.ts MV3 service worker: listener registration, rule sync, omnibox src/content/ Isolated-world content scripts. `amazon-goodreads.ts` is IIFE-bundled. @@ -374,6 +376,15 @@ the obvious edit reverses it. starter pick is written first. It comes apart from "a pick is live" for a format 1 profile arriving from Settings, or an install whose write failed: those have every shipped shortcut on and no pick on record, so `initialPicks` opens the starter set ticked rather than an empty screen. +- **`history` stays in `optional_permissions`.** Adding a permission to `permissions` makes Chrome + disable the extension on update for every existing user until they accept the new warning. + `tests/manifest.test.ts` guards it. `src/lib/history.ts` is the only file that requests or reads + it, and it treats "not granted" as no suggestions, never as an error. The request must run inside + the click handler: Chrome refuses it otherwise. +- **`suggest.ts` stays pure, like `resolve.ts`.** No `chrome.*` and no DOM, so the ranking is tested + with a plain array. A suggestion becomes a shortcut only through the ordinary New shortcut form, + so every keyword still passes `validateAlias`. Only `settings.dismissedSuggestions` persists; the + visits never do. ## Verify by executing, not by reading @@ -388,7 +399,7 @@ stubs `globalThis.chrome` and exercises the **production** path. Note that only ## The test suite -20 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and +22 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and the size is a decision rather than an accident. The question a test has to answer is: **if this vanished and the code broke, would a user notice?** diff --git a/CHANGELOG.md b/CHANGELOG.md index 87bb823..9c11b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 BunnyLol reads the ISBN off the product details (locally) and opens `goodreads.com/book/isbn/…` for that same book. Pages without an ISBN are left alone. +- **Suggest shortcuts**, on the Shortcuts page and the welcome screen. With + your permission, BunnyLol reads the last 90 days of your history locally + and offers a keyword for the sites you keep going back to that no shortcut + reaches yet. Add opens the New shortcut form already filled in, × dismisses + a site for good, and the toolbar popup lists up to three while its box is + empty. `history` is an optional permission, asked for only when you click + the button, so updating does not prompt or disable anything. The visits + are never stored or sent; only the dismissed sites are kept, and exported. ## [1.1.0] - 2026-09-02 diff --git a/PRIVACY.md b/PRIVACY.md index 0ff5ed4..8193931 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: 2026-09-07 +Last updated: 2026-09-24 ## Summary @@ -12,7 +12,10 @@ telemetry, no remote code and no network requests of its own. BunnyLol keeps one JSON value under the key `bunnylol.state.v1` (`STORAGE_KEY` in `src/lib/types.ts`) in `chrome.storage.local` on your device (`saveState` in `src/lib/storage.ts`). It holds your custom shortcuts, any shipped -shortcuts you turned off or edited, and your settings. Nothing is written to +shortcuts you turned off or edited, and your settings. The settings include +`dismissedSuggestions`, the hostnames of any shortcut suggestions you +dismissed (see below), and nothing else about the sites you visit. Like the +rest of the state, that list is in the exported file. Nothing is written to `chrome.storage.sync`. Uninstalling the extension deletes it. The extension also caches its rule-registration status under @@ -39,8 +42,8 @@ do not match are left untouched and go to the search engine as normal. ## What the extension can see -BunnyLol does not request the `tabs` permission and has no access to your -browsing history. Three places open a tab, and all of them use only +BunnyLol does not request the `tabs` permission. It has no access to your +browsing history unless you opt in, as described below. Three places open a tab, and all of them use only `chrome.tabs.create` and `chrome.tabs.update`, which do not require that permission: the toolbar popup (`src/popup/popup.ts`), the omnibox keyword (`src/background.ts`), and the welcome tab shown once on install @@ -53,6 +56,26 @@ button navigates your tab to Goodreads. The ISBN never leaves the browser except as the path of that navigation you started. Pages without an ISBN are untouched. No other site is injected into. +### Shortcut suggestions (opt-in) + +`history` is an optional permission (`optional_permissions` in +`public/manifest.json`). BunnyLol asks for it only when you click **Suggest +shortcuts**, on the Shortcuts page or the welcome screen, and Chrome shows +its own prompt. Until you accept, the extension cannot read your history. + +With the permission granted, the options page and the toolbar popup call +`chrome.history.search` for the last 90 days when they open (`loadSuggestions` +in `src/lib/history.ts`). The visits are ranked locally (`suggestShortcuts` in +`src/lib/suggest.ts`) into a few sites you might want a keyword for. Sites a +shortcut already reaches, search engines, `localhost`, IP addresses and hosts +you dismissed are skipped. The visits are never stored and never sent +anywhere; they are read again the next time either page opens. The only +thing kept is the hostname of a suggestion you dismiss with ×. + +To revoke the permission, open `chrome://extensions`, click **Details** on +BunnyLol and remove it under **Permissions**, or remove it from Chrome's +extension permission settings. Suggestions stop, and nothing else changes. + ## Third parties None. A shortcut may navigate you to a third-party site such as GitHub or diff --git a/README.md b/README.md index 5474126..0991225 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ the same book on Goodreads from its ISBN. No ISBN, no button. ![View on Goodreads button on an Amazon book page](docs/images/amazon-goodreads-button.png) +**Suggest shortcuts** (opt-in) looks at the sites you visit most and offers a keyword for each one +no shortcut reaches yet: a card on the Shortcuts page, and up to three rows in the toolbar popup +while its box is empty. Add opens the New shortcut form already filled in, and × dismisses a site +for good. It needs Chrome's optional `history` permission, requested only when you click the +button. Your history is read locally, on demand, and never stored or sent. + +![Suggested shortcuts card on the Shortcuts page](docs/images/suggestions.png) + The toolbar popup gives you autocomplete when you do not want to leave the current page:

diff --git a/docs/images/suggestions.png b/docs/images/suggestions.png new file mode 100644 index 0000000..a03cee3 Binary files /dev/null and b/docs/images/suggestions.png differ diff --git a/public/manifest.json b/public/manifest.json index 1268e1b..876a79d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -16,6 +16,7 @@ "type": "module" }, "permissions": ["storage", "declarativeNetRequest"], + "optional_permissions": ["history"], "host_permissions": [ "https://www.google.com/*", "https://www.bing.com/*", diff --git a/src/lib/history.ts b/src/lib/history.ts new file mode 100644 index 0000000..8a5a3fc --- /dev/null +++ b/src/lib/history.ts @@ -0,0 +1,62 @@ +/** + * The `chrome.history` side of shortcut suggestions: the one file that asks for + * the permission and reads the visits. `history` is an OPTIONAL permission, so + * nothing here assumes it is granted, and a profile that never opted in reads + * as no suggestions rather than an error. + * + * The visits are read on demand and never stored. What persists is only + * `settings.dismissedSuggestions`, the hosts a user said no to. + */ + +import { suggestShortcuts } from './suggest'; +import type { Suggestion } from './suggest'; +import type { Command, Settings } from './types'; + +const HISTORY = { permissions: ['history'] }; +/** How far back "a site you keep going back to" looks. */ +const WINDOW_MS = 90 * 24 * 60 * 60 * 1000; + +export async function hasHistoryAccess(): Promise { + try { + return await chrome.permissions.contains(HISTORY); + } catch { + return false; + } +} + +/** Must run inside the click handler that asked: Chrome refuses it otherwise. */ +export async function requestHistoryAccess(): Promise { + try { + return await chrome.permissions.request(HISTORY); + } catch { + return false; + } +} + +export async function loadSuggestions( + commands: Command[], + settings: Settings, + limit?: number, +): Promise { + if (!(await hasHistoryAccess())) return []; + try { + const pages = await chrome.history.search({ + text: '', + startTime: Date.now() - WINDOW_MS, + maxResults: 5000, + }); + return suggestShortcuts( + pages.map((page) => ({ + url: page.url ?? '', + title: page.title, + visitCount: page.visitCount, + typedCount: page.typedCount, + })), + commands, + settings.dismissedSuggestions, + limit, + ); + } catch { + return []; + } +} diff --git a/src/lib/storage/normalize.ts b/src/lib/storage/normalize.ts index 9391061..21bfd46 100644 --- a/src/lib/storage/normalize.ts +++ b/src/lib/storage/normalize.ts @@ -77,9 +77,24 @@ export function normalizeSettings(raw: unknown): Settings { googleAccount: normalizeAccount(source.googleAccount), interceptStopList: normalizeStopList(source.interceptStopList), dispatchToast: source.dispatchToast === true, + dismissedSuggestions: normalizeHosts(source.dismissedSuggestions), }; } +/** Enough for years of dismissals; a hand-edited file cannot grow it unbounded. */ +const MAX_DISMISSED = 500; + +function normalizeHosts(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const hosts = new Set(); + for (const entry of raw) { + if (hosts.size >= MAX_DISMISSED) break; + const host = trimmed(entry).toLowerCase(); + if (host && !/\s/.test(host)) hosts.add(host); + } + return [...hosts]; +} + /** * The exemption list. Missing means "never configured" and gets the shipped * default, which is empty: every registered keyword is intercepted until the diff --git a/src/lib/suggest.ts b/src/lib/suggest.ts new file mode 100644 index 0000000..4b4da85 --- /dev/null +++ b/src/lib/suggest.ts @@ -0,0 +1,154 @@ +/** + * Shortcut suggestions: the sites a user keeps going back to, minus the ones a + * shortcut already reaches, each with a keyword nothing else answers to. + * + * Pure, like `resolve.ts`: no `chrome.*` and no DOM. The pages come from + * `chrome.history` through `lib/history.ts`, which is the only file that knows + * where they came from, so this ranking is testable with a plain array. + * + * A suggestion only ever becomes a shortcut through the ordinary New shortcut + * form (`prefillFor` → `#new?prefill=`), so nothing here writes, and every + * keyword still meets `validateAlias` on the way in. + */ + +import { SEARCH_ENGINES } from './commands'; +import { buildKeyMap } from './resolve'; +import type { Command } from './types'; +import { validateAlias } from './validate'; + +export interface VisitedPage { + url: string; + title?: string; + visitCount?: number; + typedCount?: number; +} + +export interface Suggestion { + alias: string; + /** The site's origin: a shortcut to the home page, never to one deep link. */ + url: string; + name: string; + /** Hostname without `www.`: what a dismissal records. */ + host: string; + score: number; +} + +/** Typing an address is the habit a keyword replaces, so it counts triple. */ +const TYPED_WEIGHT = 3; +/** Below this a site was visited, not returned to. */ +const MIN_SCORE = 5; + +export function suggestShortcuts( + pages: VisitedPage[], + commands: Command[], + dismissed: string[], + limit = 5, +): Suggestion[] { + const skip = new Set(dismissed.map((host) => host.toLowerCase())); + for (const engine of SEARCH_ENGINES) skip.add(bareHost(engine.host)); + for (const cmd of commands) { + for (const url of [cmd.url, cmd.searchUrl]) { + const host = hostOf(url ?? ''); + if (host) skip.add(host); + } + } + + const sites = new Map(); + for (const page of pages) { + let url: URL; + try { + url = new URL(page.url); + } catch { + continue; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') continue; + const host = bareHost(url.hostname); + if (skip.has(host) || !isPublicHost(host)) continue; + const score = (page.visitCount ?? 0) + TYPED_WEIGHT * (page.typedCount ?? 0); + const site = sites.get(host) ?? { score: 0, origin: `${url.origin}/`, title: '', best: -1 }; + site.score += score; + // The name comes off the most visited page of the site, which is usually + // the one titled after the site rather than after one document on it. + if (score > site.best) { + site.best = score; + site.title = page.title ?? ''; + } + sites.set(host, site); + } + + const taken = new Set(buildKeyMap(commands).keys()); + const out: Suggestion[] = []; + const ranked = [...sites].filter(([, s]) => s.score >= MIN_SCORE); + ranked.sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0])); + for (const [host, site] of ranked) { + if (out.length >= limit) break; + const alias = pickAlias(host, taken); + if (!alias) continue; + taken.add(alias); + out.push({ + alias, + url: site.origin, + name: siteName(site.title, host), + host, + score: site.score, + }); + } + return out; +} + +/** The `#new?prefill=` text `parsePrefill` reads back: keyword, URL, name. */ +export function prefillFor(s: Suggestion): string { + return `${s.alias} ${s.url} ${s.name}`; +} + +function hostOf(url: string): string { + try { + return bareHost(new URL(url).hostname); + } catch { + return ''; + } +} + +function bareHost(host: string): string { + return host.toLowerCase().replace(/^www\./, ''); +} + +function isPublicHost(host: string): boolean { + if (!host.includes('.') || host.endsWith('.local') || host.endsWith('.localhost')) return false; + // An IPv4 address or a bracketed IPv6 one names a machine, not a site. + return !/^[\d.]+$/.test(host) && !host.startsWith('['); +} + +/** + * The label a person would call the site by: `linear.app` → `linear`, + * `mail.proton.me` → `proton`, `bbc.co.uk` → `bbc`. Then the subdomain + * (`docs.google.com` → `docs`), then a prefix, then a numbered one. + */ +function pickAlias(host: string, taken: Set): string { + const main = mainLabel(host); + const labels = host.split('.'); + const candidates = [main, labels[0]!, main.slice(0, 2), main.slice(0, 3)]; + for (let n = 2; n < 10; n++) candidates.push(`${main}${n}`); + for (const candidate of candidates) { + const check = validateAlias(candidate.replace(/[^a-z0-9-]/g, '')); + if (check.ok && check.alias.length > 1 && !taken.has(check.alias)) return check.alias; + } + return ''; +} + +function mainLabel(host: string): string { + const labels = host.split('.'); + // ponytail: no public-suffix list. Two short trailing labels (co.uk, com.au) + // are read as one suffix; a rarer shape just gets a less obvious keyword. + const suffix = + labels.length > 2 && labels.at(-1)!.length <= 3 && labels.at(-2)!.length <= 3 ? 2 : 1; + return labels[labels.length - suffix - 1] ?? labels[0]!; +} + +/** `Linear – Plan and build products` → `Linear`; no title → `Linear` off the host. */ +function siteName(title: string, host: string): string { + const lead = title.split(/\s+[|\-–—·:]\s+/)[0]?.trim() ?? ''; + if (lead && lead.length <= 40) return lead; + const label = mainLabel(host); + return label.charAt(0).toUpperCase() + label.slice(1); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index d82f879..9389965 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -170,6 +170,11 @@ export interface Settings { * as "off". Grep landed you here: there is no toast left to find. */ dispatchToast: boolean; + /** + * Hosts (no `www.`) the user dismissed from shortcut suggestions, so they are + * never offered again. See `lib/suggest.ts`. + */ + dismissedSuggestions: string[]; } export type SearchEngineId = 'google' | 'bing' | 'duckduckgo'; @@ -333,6 +338,7 @@ export const DEFAULT_SETTINGS: Settings = { googleAccount: 0, interceptStopList: [...DEFAULT_STOP_LIST], dispatchToast: false, + dismissedSuggestions: [], }; export const DEFAULT_OVERRIDES: Overrides = { diff --git a/src/options/dom.ts b/src/options/dom.ts index 816b72e..d31521d 100644 --- a/src/options/dom.ts +++ b/src/options/dom.ts @@ -14,11 +14,12 @@ export function button(label: string, onClick: () => void, className = 'btn'): H return node; } -/** The two glyphs the row actions use, as path data for a 16px viewBox. Built +/** The glyphs the row actions use, as path data for a 16px viewBox. Built * with `createElementNS` rather than markup so nothing here ever parses HTML. */ const ICONS = { pencil: 'M11.5 2.5a1.4 1.4 0 0 1 2 2L6 12l-3 1 1-3 7.5-7.5zM10 4l2 2', trash: 'M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M6.8 7v4M9.2 7v4', + close: 'M4.5 4.5l7 7M11.5 4.5l-7 7', } as const; export function icon(name: keyof typeof ICONS): SVGElement { diff --git a/src/options/options.css b/src/options/options.css index 0fd2a82..1de554c 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -690,6 +690,17 @@ a { } } +/* The suggestions host is empty until history is read, and stays empty with + nothing to offer; it must not take a gap in the panel meanwhile. */ +.suggest:empty { + display: none; +} + +/* A suggestion's Add is the whole point of the row, so it is not hover-only. */ +.suggest .row-actions .btn { + opacity: 1; +} + .empty { padding: var(--sp-9) var(--sp-7); font-size: var(--fs-13); diff --git a/src/options/views/browse.ts b/src/options/views/browse.ts index 9d3d581..130bb1b 100644 --- a/src/options/views/browse.ts +++ b/src/options/views/browse.ts @@ -38,6 +38,7 @@ import { getCommands, getFilter, getState, setFilter, takeNotice } from '../stor import type { GroupRef, RowRef, RunRef } from './browse-groups'; import { makeGroup, makeRun, move, place, rowsOf, turnOn } from './browse-groups'; import { renderRow } from './browse-row'; +import { renderSuggestions } from './suggestions'; /** The group every switched-off shortcut is drawn under, last on the page. */ const HIDDEN_TITLE = 'Hidden shortcuts'; @@ -128,6 +129,7 @@ export function renderBrowse(): Node[] { const groups = el('div', { class: 'groups' }); const empty = el('div', { class: 'empty' }); empty.hidden = true; + const suggestions = renderSuggestions(); const groupRefs: GroupRef[] = []; const runRefs: RunRef[] = []; @@ -306,6 +308,7 @@ export function renderBrowse(): Node[] { toolbarActions, ], }), + suggestions, groups, empty, ], @@ -362,6 +365,7 @@ export function renderBrowse(): Node[] { // this too, so there is one place the filter's effect on the page is // decided. toolbarActions.hidden = query !== ''; + suggestions.hidden = query !== ''; // `suggest()` gives keyword-first ranking; the substring pass then widens it // to descriptions so the box behaves like a filter and not just a launcher. diff --git a/src/options/views/suggestions.ts b/src/options/views/suggestions.ts new file mode 100644 index 0000000..520f46f --- /dev/null +++ b/src/options/views/suggestions.ts @@ -0,0 +1,111 @@ +/** + * The "Suggested shortcuts" card at the top of the Shortcuts route: sites the + * user keeps going back to, each one click from a prefilled New shortcut form. + * + * It sits outside the rows and groups and writes nothing `applyFilter` owns. + * Hiding it while a query is live is `applyFilter`'s call, on the host this + * returns; this file never writes `hidden`. With nothing to offer it leaves the + * host empty and `.suggest:empty` takes it off the page. + * + * A suggestion's name comes off a page title in the user's history, which is + * untrusted text, so it reaches the DOM only through `el` (invariant 11). + */ + +import { hasHistoryAccess, loadSuggestions, requestHistoryAccess } from '../../lib/history'; +import { prefillFor } from '../../lib/suggest'; +import type { Suggestion } from '../../lib/suggest'; +import { prettyUrl } from '../../lib/text'; +import { el } from '../../ui/dom'; +import { button, iconButton, panelCard } from '../dom'; +import { go } from '../router'; +import { commitSettings, getCommands, getState, reportFailure } from '../store'; + +const TITLE = 'Suggested shortcuts'; +const OFFER = + 'Suggest shortcuts for sites you visit often. BunnyLol reads your browsing history on this device only; nothing leaves your browser.'; + +/** Returned empty and filled once the history has been read, which is async. */ +export function renderSuggestions(): HTMLElement { + const host = el('div', { class: 'suggest' }); + void fill(host, false); + return host; +} + +async function fill(host: HTMLElement, refocus: boolean): Promise { + const granted = await hasHistoryAccess(); + const found = granted ? await loadSuggestions(getCommands(), getState().settings) : []; + // The page may have re-rendered while the history was being read, and taken + // this host with it. + if (!host.isConnected) return; + host.textContent = ''; + + if (!granted) { + const card = panelCard(TITLE, OFFER); + card.body.append( + el('div', { + class: 'btn-row', + children: [ + // The request runs straight from the click: Chrome refuses one that + // is not a direct response to a user gesture. + button( + 'Suggest shortcuts', + () => + void requestHistoryAccess().then((ok) => { + if (ok) void fill(host, true); + }), + 'btn btn-sm', + ), + ], + }), + ); + host.append(card.section); + return; + } + if (found.length === 0) return; + + const card = panelCard(TITLE, 'Sites you keep going back to that no shortcut reaches yet.'); + card.body.append( + el('div', { class: 'rows', children: found.map((s) => suggestionRow(s, host)) }), + ); + host.append(card.section); + // The button just pressed went out with the old card, which drops focus on + // ``; the first control of the new one is the nearest thing to it. + if (refocus) host.querySelector('button')?.focus(); +} + +function suggestionRow(s: Suggestion, host: HTMLElement): HTMLElement { + return el('div', { + class: 'row', + children: [ + el('div', { class: 'row-keys', children: [el('code', { class: 'chip', text: s.alias })] }), + el('div', { + class: 'row-body', + children: [ + el('div', { class: 'row-name', text: s.name }), + el('div', { class: 'row-url', text: prettyUrl(s.url), title: s.url }), + ], + }), + el('div', { + class: 'row-actions', + children: [ + button( + 'Add', + () => go(`#new?prefill=${encodeURIComponent(prefillFor(s))}`), + 'btn btn-sm', + ), + iconButton(`Dismiss ${s.name}`, 'close', () => { + const settings = getState().settings; + void commitSettings({ + ...settings, + dismissedSuggestions: [...settings.dismissedSuggestions, s.host], + }).catch(reportFailure); + // Our own write comes back as an echo, and an echo does not + // re-render, so the card refills itself. `commitSettings` applies + // the dismissal before it awaits, so the reload already skips it. + void fill(host, true); + }), + ], + }), + ], + }); +} diff --git a/src/options/views/welcome.ts b/src/options/views/welcome.ts index 57b5a46..866d177 100644 --- a/src/options/views/welcome.ts +++ b/src/options/views/welcome.ts @@ -13,6 +13,7 @@ * the escape-hatch explainer, and Skip. */ +import { hasHistoryAccess, requestHistoryAccess } from '../../lib/history'; import { FORCE_SEARCH_PREFIXES } from '../../lib/types'; import { el } from '../../ui/dom'; import { button } from '../dom'; @@ -45,6 +46,7 @@ export function renderWelcome(): Node[] { ' BunnyLol is an independent open-source project.', }), ...choice.nodes, + historyOffer(), escapeNote(), ]; @@ -62,6 +64,34 @@ export function renderWelcome(): Node[] { return [el('section', { class: 'welcome', children: nodes })]; } +/** + * The opt-in to shortcut suggestions. Not a pack and not part of the pick: it + * asks Chrome for the optional `history` permission and writes no state, so + * Skip and Continue mean what they meant before. + */ +function historyOffer(): HTMLElement { + const ask = button( + 'Suggest shortcuts from my history', + // Straight from the click: Chrome refuses a request without a user gesture. + () => void requestHistoryAccess().then((ok) => ok && done()), + 'btn btn-sm', + ); + const done = (): void => { + ask.textContent = 'On: suggestions appear on your Shortcuts page'; + ask.disabled = true; + }; + void hasHistoryAccess().then((ok) => ok && done()); + return el('div', { + class: 'btn-row', + children: [ + el('p', { + text: 'Want suggestions too? BunnyLol can suggest shortcuts for the sites you visit most.', + }), + ask, + ], + }); +} + /** * The escape hatch, stated on the one screen everybody sees. The first word of * a query is always a command when it matches one, which is only liveable diff --git a/src/popup/popup.css b/src/popup/popup.css index f443cb7..f4722c1 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -88,17 +88,67 @@ body { text-overflow: ellipsis; } -.results { - /* Fixed height, not max-height: the box keeps its size as matches come and go. */ +/* Fixed height, not max-height: the box keeps its size as matches come and go, + and as the suggestions above the list appear and disappear. */ +.pane { + display: flex; + flex-direction: column; height: var(--popup-list-h); + border-top: 1px solid var(--border); +} + +.results { + flex: 1; + min-height: 0; margin: 0; padding: var(--sp-2); overflow-y: auto; overscroll-behavior: contain; - border-top: 1px solid var(--border); list-style: none; } +.suggested { + flex: none; + padding: var(--sp-2) var(--sp-2) 0; + border-bottom: 1px solid var(--border); +} + +.suggested[hidden] { + display: none; +} + +.suggested-label { + margin: 0; + padding: var(--sp-2) var(--sp-4); + color: var(--text-faint); + font-size: var(--fs-11); + font-weight: var(--fw-medium); +} + +/* A button wearing the row's clothes: Tab reaches it, Enter and Space press it. */ +.suggestion { + width: 100%; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.suggestion:last-child { + margin-bottom: var(--sp-2); +} + +.suggestion:hover { + background: var(--bg-sunken); +} + +.suggestion:focus-visible { + outline: 2px solid var(--ring); + outline-offset: -2px; +} + .results::-webkit-scrollbar { width: 8px; } diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 6ccfbb9..094183f 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -7,8 +7,11 @@ * resolver and `chrome.tabs`. */ +import { loadSuggestions } from '../lib/history'; import { resolve, suggest } from '../lib/resolve'; import { loadResolveContext } from '../lib/storage'; +import { prefillFor } from '../lib/suggest'; +import type { Suggestion } from '../lib/suggest'; import { errorText, firstToken, prettyUrl, restOfLine } from '../lib/text'; import { toNavigableUrl } from '../lib/url'; import type { Command, Settings } from '../lib/types'; @@ -69,6 +72,17 @@ list.id = 'results'; list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Matching shortcuts'); +// Buttons, not listbox options: Tab from the empty input reaches them, and the +// combobox's arrow-key model stays about the list alone. +const suggested = el('section', { + class: 'suggested', + attrs: { 'aria-label': 'Suggested shortcuts' }, +}); +suggested.hidden = true; + +const pane = el('div', { class: 'pane' }); +pane.append(suggested, list); + const optionsButton = el('button', { class: 'link', text: 'Manage shortcuts' }); optionsButton.type = 'button'; @@ -80,7 +94,7 @@ footer.append( const bar = el('div', { class: 'bar' }); bar.append(input); -root.append(bar, dest, list, footer); +root.append(bar, dest, pane, footer); // ------------------------------------------------------------- rendering --- @@ -101,10 +115,39 @@ function render(): void { ); } list.scrollTop = 0; + suggested.hidden = input.value !== '' || suggested.childElementCount === 0; input.setAttribute('aria-expanded', matches.length > 0 ? 'true' : 'false'); renderDest(); } +function renderSuggestions(suggestions: Suggestion[]): void { + if (suggestions.length === 0) return; + suggested.replaceChildren( + el('h2', { class: 'suggested-label', text: 'Suggested shortcuts' }), + ...suggestions.map(buildSuggestion), + ); + suggested.hidden = input.value !== ''; +} + +/** Opens the ordinary New shortcut form prefilled: the popup never writes a shortcut itself. */ +function buildSuggestion(s: Suggestion): HTMLButtonElement { + const button = el('button', { class: 'row suggestion' }); + button.type = 'button'; + const text = el('span', { class: 'row-text' }); + text.append( + el('span', { class: 'row-name', text: s.name }), + el('span', { class: 'row-desc', text: prettyUrl(s.url) }), + ); + button.append(el('span', { class: 'row-key', text: s.alias }), text); + button.addEventListener('click', () => { + const url = chrome.runtime.getURL( + `options.html#new?prefill=${encodeURIComponent(prefillFor(s))}`, + ); + void chrome.tabs.create({ url }).then(() => window.close()); + }); + return button; +} + function buildRow(cmd: Command, keyword: string, index: number): HTMLLIElement { const item = el('li', { class: 'row' }); item.id = `row-${index}`; @@ -353,6 +396,8 @@ const readyPromise = loadResolveContext().then((context) => { ready = true; // The user may already have typed while storage was loading. render(); + // Never asks for `history`: a permission prompt would close the popup. + void loadSuggestions(commands, settings, 3).then(renderSuggestions); }); // Attached separately so `readyPromise` still rejects for `navigate`, which diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index be1e5e6..7d7a02a 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -36,4 +36,11 @@ describe('manifest', () => { // store has to re-review, so widening this is a deliberate edit here first. expect([...MANIFEST.host_permissions].sort()).toEqual(origins); }); + + it('asks for history only when the user opts in to suggestions', () => { + // A new REQUIRED permission makes Chrome disable the extension for every + // existing user until they accept it on chrome://extensions. + expect(MANIFEST.permissions).not.toContain('history'); + expect(MANIFEST.optional_permissions).toEqual(['history']); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index d7c22ff..6756292 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -36,6 +36,7 @@ const STATE: StoredState = { googleAccount: 2, interceptStopList: ['new', 'r'], dispatchToast: true, + dismissedSuggestions: ['pitchfork.com'], }, }; diff --git a/tests/suggest.test.ts b/tests/suggest.test.ts new file mode 100644 index 0000000..fa7f421 --- /dev/null +++ b/tests/suggest.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { BUILTIN_COMMANDS } from '../src/lib/commands'; +import { parsePrefill } from '../src/lib/draft'; +import { mergeCommands } from '../src/lib/resolve'; +import { prefillFor, suggestShortcuts } from '../src/lib/suggest'; +import { DEFAULT_OVERRIDES } from '../src/lib/types'; +import { validateAlias } from '../src/lib/validate'; + +const commands = mergeCommands(BUILTIN_COMMANDS, DEFAULT_OVERRIDES); + +describe('suggestShortcuts', () => { + it('ranks sites by visits, typed visits counting triple, one per host', () => { + const out = suggestShortcuts( + [ + { url: 'https://pitchfork.com/reviews/1', visitCount: 6, title: 'Issue 1' }, + { url: 'https://pitchfork.com/', visitCount: 10, title: 'Pitchfork – Music reviews' }, + { url: 'https://www.kagi.com/settings', visitCount: 3, typedCount: 5 }, + { url: 'https://rarely.example/', visitCount: 2 }, + ], + commands, + [], + ); + expect(out.map((s) => [s.host, s.url])).toEqual([ + ['kagi.com', 'https://www.kagi.com/'], + ['pitchfork.com', 'https://pitchfork.com/'], + ]); + expect(out[1]!.name).toBe('Pitchfork'); + }); + + it('skips what a shortcut already reaches, what was dismissed, and non-sites', () => { + const pages = [ + 'https://github.com/facebook/react', + 'https://www.google.com/search?q=x', + 'https://notion.so/page', + 'http://localhost:3000/', + 'http://192.168.1.1/', + 'chrome://settings/', + ].map((url) => ({ url, visitCount: 50 })); + expect(suggestShortcuts(pages, commands, ['notion.so'])).toEqual([]); + }); + + it('falls back to another keyword when the obvious one is taken', () => { + const [s] = suggestShortcuts([{ url: 'https://gh.example.com/', visitCount: 9 }], commands, []); + expect(s!.alias).not.toBe('gh'); + expect(validateAlias(s!.alias).ok).toBe(true); + expect(commands.some((c) => c.keys.includes(s!.alias))).toBe(false); + }); + + it('opens the New shortcut form with the keyword and the site filled in', () => { + const [s] = suggestShortcuts([{ url: 'https://pitchfork.com/x', visitCount: 9 }], commands, []); + const draft = parsePrefill(prefillFor(s!)); + expect(draft.keys).toBe('pitchfork'); + expect(draft.url).toBe('https://pitchfork.com/'); + }); +});