Skip to content

Commit a039341

Browse files
authored
Merge pull request #20 from ion05/feat/suggest-shortcuts
Suggest shortcuts for sites you visit often
2 parents 1d4e585 + 45e64f9 commit a039341

20 files changed

Lines changed: 613 additions & 10 deletions

‎AGENTS.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ src/lib/draft.ts What the edit form edits, and the pure parsing around it
5858
src/lib/text.ts String helpers every surface shares
5959
src/lib/url.ts Small URL helpers
6060
src/lib/amazon-book.ts Amazon product HTML → ISBN → Goodreads URL. Pure.
61+
src/lib/suggest.ts `suggestShortcuts`: visited pages → ranked keyword suggestions. Pure.
62+
src/lib/history.ts The only `chrome.history` caller: optional-permission check, request, read
6163
src/lib/install.ts The onInstalled branch: starter pick, rule sync, welcome tab
6264
src/background.ts MV3 service worker: listener registration, rule sync, omnibox
6365
src/content/ Isolated-world content scripts. `amazon-goodreads.ts` is IIFE-bundled.
@@ -374,6 +376,15 @@ the obvious edit reverses it.
374376
starter pick is written first. It comes apart from "a pick is live" for a format 1 profile
375377
arriving from Settings, or an install whose write failed: those have every shipped shortcut on and
376378
no pick on record, so `initialPicks` opens the starter set ticked rather than an empty screen.
379+
- **`history` stays in `optional_permissions`.** Adding a permission to `permissions` makes Chrome
380+
disable the extension on update for every existing user until they accept the new warning.
381+
`tests/manifest.test.ts` guards it. `src/lib/history.ts` is the only file that requests or reads
382+
it, and it treats "not granted" as no suggestions, never as an error. The request must run inside
383+
the click handler: Chrome refuses it otherwise.
384+
- **`suggest.ts` stays pure, like `resolve.ts`.** No `chrome.*` and no DOM, so the ranking is tested
385+
with a plain array. A suggestion becomes a shortcut only through the ordinary New shortcut form,
386+
so every keyword still passes `validateAlias`. Only `settings.dismissedSuggestions` persists; the
387+
visits never do.
377388

378389
## Verify by executing, not by reading
379390

@@ -388,7 +399,7 @@ stubs `globalThis.chrome` and exercises the **production** path. Note that only
388399

389400
## The test suite
390401

391-
20 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
402+
22 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
392403
the size is a decision rather than an accident. The question a test has to answer is: **if this
393404
vanished and the code broke, would a user notice?**
394405

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
BunnyLol reads the ISBN off the product details (locally) and opens
1414
`goodreads.com/book/isbn/…` for that same book. Pages without an ISBN are
1515
left alone.
16+
- **Suggest shortcuts**, on the Shortcuts page and the welcome screen. With
17+
your permission, BunnyLol reads the last 90 days of your history locally
18+
and offers a keyword for the sites you keep going back to that no shortcut
19+
reaches yet. Add opens the New shortcut form already filled in, × dismisses
20+
a site for good, and the toolbar popup lists up to three while its box is
21+
empty. `history` is an optional permission, asked for only when you click
22+
the button, so updating does not prompt or disable anything. The visits
23+
are never stored or sent; only the dismissed sites are kept, and exported.
1624

1725
## [1.1.0] - 2026-09-02
1826

‎PRIVACY.md‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Privacy Policy
22

3-
Last updated: 2026-09-07
3+
Last updated: 2026-09-24
44

55
## Summary
66

@@ -12,7 +12,10 @@ telemetry, no remote code and no network requests of its own.
1212
BunnyLol keeps one JSON value under the key `bunnylol.state.v1` (`STORAGE_KEY`
1313
in `src/lib/types.ts`) in `chrome.storage.local` on your device (`saveState`
1414
in `src/lib/storage.ts`). It holds your custom shortcuts, any shipped
15-
shortcuts you turned off or edited, and your settings. Nothing is written to
15+
shortcuts you turned off or edited, and your settings. The settings include
16+
`dismissedSuggestions`, the hostnames of any shortcut suggestions you
17+
dismissed (see below), and nothing else about the sites you visit. Like the
18+
rest of the state, that list is in the exported file. Nothing is written to
1619
`chrome.storage.sync`. Uninstalling the extension deletes it.
1720

1821
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.
3942

4043
## What the extension can see
4144

42-
BunnyLol does not request the `tabs` permission and has no access to your
43-
browsing history. Three places open a tab, and all of them use only
45+
BunnyLol does not request the `tabs` permission. It has no access to your
46+
browsing history unless you opt in, as described below. Three places open a tab, and all of them use only
4447
`chrome.tabs.create` and `chrome.tabs.update`, which do not require that
4548
permission: the toolbar popup (`src/popup/popup.ts`), the omnibox keyword
4649
(`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
5356
except as the path of that navigation you started. Pages without an ISBN are
5457
untouched. No other site is injected into.
5558

59+
### Shortcut suggestions (opt-in)
60+
61+
`history` is an optional permission (`optional_permissions` in
62+
`public/manifest.json`). BunnyLol asks for it only when you click **Suggest
63+
shortcuts**, on the Shortcuts page or the welcome screen, and Chrome shows
64+
its own prompt. Until you accept, the extension cannot read your history.
65+
66+
With the permission granted, the options page and the toolbar popup call
67+
`chrome.history.search` for the last 90 days when they open (`loadSuggestions`
68+
in `src/lib/history.ts`). The visits are ranked locally (`suggestShortcuts` in
69+
`src/lib/suggest.ts`) into a few sites you might want a keyword for. Sites a
70+
shortcut already reaches, search engines, `localhost`, IP addresses and hosts
71+
you dismissed are skipped. The visits are never stored and never sent
72+
anywhere; they are read again the next time either page opens. The only
73+
thing kept is the hostname of a suggestion you dismiss with ×.
74+
75+
To revoke the permission, open `chrome://extensions`, click **Details** on
76+
BunnyLol and remove it under **Permissions**, or remove it from Chrome's
77+
extension permission settings. Suggestions stop, and nothing else changes.
78+
5679
## Third parties
5780

5881
None. A shortcut may navigate you to a third-party site such as GitHub or

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ the same book on Goodreads from its ISBN. No ISBN, no button.
4444

4545
![View on Goodreads button on an Amazon book page](docs/images/amazon-goodreads-button.png)
4646

47+
**Suggest shortcuts** (opt-in) looks at the sites you visit most and offers a keyword for each one
48+
no shortcut reaches yet: a card on the Shortcuts page, and up to three rows in the toolbar popup
49+
while its box is empty. Add opens the New shortcut form already filled in, and × dismisses a site
50+
for good. It needs Chrome's optional `history` permission, requested only when you click the
51+
button. Your history is read locally, on demand, and never stored or sent.
52+
53+
![Suggested shortcuts card on the Shortcuts page](docs/images/suggestions.png)
54+
4755
The toolbar popup gives you autocomplete when you do not want to leave the current page:
4856

4957
<p align="center">

‎docs/images/suggestions.png‎

52.1 KB
Loading

‎public/manifest.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"type": "module"
1717
},
1818
"permissions": ["storage", "declarativeNetRequest"],
19+
"optional_permissions": ["history"],
1920
"host_permissions": [
2021
"https://www.google.com/*",
2122
"https://www.bing.com/*",

‎src/lib/history.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* The `chrome.history` side of shortcut suggestions: the one file that asks for
3+
* the permission and reads the visits. `history` is an OPTIONAL permission, so
4+
* nothing here assumes it is granted, and a profile that never opted in reads
5+
* as no suggestions rather than an error.
6+
*
7+
* The visits are read on demand and never stored. What persists is only
8+
* `settings.dismissedSuggestions`, the hosts a user said no to.
9+
*/
10+
11+
import { suggestShortcuts } from './suggest';
12+
import type { Suggestion } from './suggest';
13+
import type { Command, Settings } from './types';
14+
15+
const HISTORY = { permissions: ['history'] };
16+
/** How far back "a site you keep going back to" looks. */
17+
const WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
18+
19+
export async function hasHistoryAccess(): Promise<boolean> {
20+
try {
21+
return await chrome.permissions.contains(HISTORY);
22+
} catch {
23+
return false;
24+
}
25+
}
26+
27+
/** Must run inside the click handler that asked: Chrome refuses it otherwise. */
28+
export async function requestHistoryAccess(): Promise<boolean> {
29+
try {
30+
return await chrome.permissions.request(HISTORY);
31+
} catch {
32+
return false;
33+
}
34+
}
35+
36+
export async function loadSuggestions(
37+
commands: Command[],
38+
settings: Settings,
39+
limit?: number,
40+
): Promise<Suggestion[]> {
41+
if (!(await hasHistoryAccess())) return [];
42+
try {
43+
const pages = await chrome.history.search({
44+
text: '',
45+
startTime: Date.now() - WINDOW_MS,
46+
maxResults: 5000,
47+
});
48+
return suggestShortcuts(
49+
pages.map((page) => ({
50+
url: page.url ?? '',
51+
title: page.title,
52+
visitCount: page.visitCount,
53+
typedCount: page.typedCount,
54+
})),
55+
commands,
56+
settings.dismissedSuggestions,
57+
limit,
58+
);
59+
} catch {
60+
return [];
61+
}
62+
}

‎src/lib/storage/normalize.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,24 @@ export function normalizeSettings(raw: unknown): Settings {
7777
googleAccount: normalizeAccount(source.googleAccount),
7878
interceptStopList: normalizeStopList(source.interceptStopList),
7979
dispatchToast: source.dispatchToast === true,
80+
dismissedSuggestions: normalizeHosts(source.dismissedSuggestions),
8081
};
8182
}
8283

84+
/** Enough for years of dismissals; a hand-edited file cannot grow it unbounded. */
85+
const MAX_DISMISSED = 500;
86+
87+
function normalizeHosts(raw: unknown): string[] {
88+
if (!Array.isArray(raw)) return [];
89+
const hosts = new Set<string>();
90+
for (const entry of raw) {
91+
if (hosts.size >= MAX_DISMISSED) break;
92+
const host = trimmed(entry).toLowerCase();
93+
if (host && !/\s/.test(host)) hosts.add(host);
94+
}
95+
return [...hosts];
96+
}
97+
8398
/**
8499
* The exemption list. Missing means "never configured" and gets the shipped
85100
* default, which is empty: every registered keyword is intercepted until the

‎src/lib/suggest.ts‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Shortcut suggestions: the sites a user keeps going back to, minus the ones a
3+
* shortcut already reaches, each with a keyword nothing else answers to.
4+
*
5+
* Pure, like `resolve.ts`: no `chrome.*` and no DOM. The pages come from
6+
* `chrome.history` through `lib/history.ts`, which is the only file that knows
7+
* where they came from, so this ranking is testable with a plain array.
8+
*
9+
* A suggestion only ever becomes a shortcut through the ordinary New shortcut
10+
* form (`prefillFor` → `#new?prefill=`), so nothing here writes, and every
11+
* keyword still meets `validateAlias` on the way in.
12+
*/
13+
14+
import { SEARCH_ENGINES } from './commands';
15+
import { buildKeyMap } from './resolve';
16+
import type { Command } from './types';
17+
import { validateAlias } from './validate';
18+
19+
export interface VisitedPage {
20+
url: string;
21+
title?: string;
22+
visitCount?: number;
23+
typedCount?: number;
24+
}
25+
26+
export interface Suggestion {
27+
alias: string;
28+
/** The site's origin: a shortcut to the home page, never to one deep link. */
29+
url: string;
30+
name: string;
31+
/** Hostname without `www.`: what a dismissal records. */
32+
host: string;
33+
score: number;
34+
}
35+
36+
/** Typing an address is the habit a keyword replaces, so it counts triple. */
37+
const TYPED_WEIGHT = 3;
38+
/** Below this a site was visited, not returned to. */
39+
const MIN_SCORE = 5;
40+
41+
export function suggestShortcuts(
42+
pages: VisitedPage[],
43+
commands: Command[],
44+
dismissed: string[],
45+
limit = 5,
46+
): Suggestion[] {
47+
const skip = new Set(dismissed.map((host) => host.toLowerCase()));
48+
for (const engine of SEARCH_ENGINES) skip.add(bareHost(engine.host));
49+
for (const cmd of commands) {
50+
for (const url of [cmd.url, cmd.searchUrl]) {
51+
const host = hostOf(url ?? '');
52+
if (host) skip.add(host);
53+
}
54+
}
55+
56+
const sites = new Map<string, { score: number; origin: string; title: string; best: number }>();
57+
for (const page of pages) {
58+
let url: URL;
59+
try {
60+
url = new URL(page.url);
61+
} catch {
62+
continue;
63+
}
64+
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
65+
const host = bareHost(url.hostname);
66+
if (skip.has(host) || !isPublicHost(host)) continue;
67+
const score = (page.visitCount ?? 0) + TYPED_WEIGHT * (page.typedCount ?? 0);
68+
const site = sites.get(host) ?? { score: 0, origin: `${url.origin}/`, title: '', best: -1 };
69+
site.score += score;
70+
// The name comes off the most visited page of the site, which is usually
71+
// the one titled after the site rather than after one document on it.
72+
if (score > site.best) {
73+
site.best = score;
74+
site.title = page.title ?? '';
75+
}
76+
sites.set(host, site);
77+
}
78+
79+
const taken = new Set(buildKeyMap(commands).keys());
80+
const out: Suggestion[] = [];
81+
const ranked = [...sites].filter(([, s]) => s.score >= MIN_SCORE);
82+
ranked.sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0]));
83+
for (const [host, site] of ranked) {
84+
if (out.length >= limit) break;
85+
const alias = pickAlias(host, taken);
86+
if (!alias) continue;
87+
taken.add(alias);
88+
out.push({
89+
alias,
90+
url: site.origin,
91+
name: siteName(site.title, host),
92+
host,
93+
score: site.score,
94+
});
95+
}
96+
return out;
97+
}
98+
99+
/** The `#new?prefill=` text `parsePrefill` reads back: keyword, URL, name. */
100+
export function prefillFor(s: Suggestion): string {
101+
return `${s.alias} ${s.url} ${s.name}`;
102+
}
103+
104+
function hostOf(url: string): string {
105+
try {
106+
return bareHost(new URL(url).hostname);
107+
} catch {
108+
return '';
109+
}
110+
}
111+
112+
function bareHost(host: string): string {
113+
return host.toLowerCase().replace(/^www\./, '');
114+
}
115+
116+
function isPublicHost(host: string): boolean {
117+
if (!host.includes('.') || host.endsWith('.local') || host.endsWith('.localhost')) return false;
118+
// An IPv4 address or a bracketed IPv6 one names a machine, not a site.
119+
return !/^[\d.]+$/.test(host) && !host.startsWith('[');
120+
}
121+
122+
/**
123+
* The label a person would call the site by: `linear.app` → `linear`,
124+
* `mail.proton.me` → `proton`, `bbc.co.uk` → `bbc`. Then the subdomain
125+
* (`docs.google.com` → `docs`), then a prefix, then a numbered one.
126+
*/
127+
function pickAlias(host: string, taken: Set<string>): string {
128+
const main = mainLabel(host);
129+
const labels = host.split('.');
130+
const candidates = [main, labels[0]!, main.slice(0, 2), main.slice(0, 3)];
131+
for (let n = 2; n < 10; n++) candidates.push(`${main}${n}`);
132+
for (const candidate of candidates) {
133+
const check = validateAlias(candidate.replace(/[^a-z0-9-]/g, ''));
134+
if (check.ok && check.alias.length > 1 && !taken.has(check.alias)) return check.alias;
135+
}
136+
return '';
137+
}
138+
139+
function mainLabel(host: string): string {
140+
const labels = host.split('.');
141+
// ponytail: no public-suffix list. Two short trailing labels (co.uk, com.au)
142+
// are read as one suffix; a rarer shape just gets a less obvious keyword.
143+
const suffix =
144+
labels.length > 2 && labels.at(-1)!.length <= 3 && labels.at(-2)!.length <= 3 ? 2 : 1;
145+
return labels[labels.length - suffix - 1] ?? labels[0]!;
146+
}
147+
148+
/** `Linear – Plan and build products` → `Linear`; no title → `Linear` off the host. */
149+
function siteName(title: string, host: string): string {
150+
const lead = title.split(/\s+[|\-–—·:]\s+/)[0]?.trim() ?? '';
151+
if (lead && lead.length <= 40) return lead;
152+
const label = mainLabel(host);
153+
return label.charAt(0).toUpperCase() + label.slice(1);
154+
}

‎src/lib/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ export interface Settings {
170170
* as "off". Grep landed you here: there is no toast left to find.
171171
*/
172172
dispatchToast: boolean;
173+
/**
174+
* Hosts (no `www.`) the user dismissed from shortcut suggestions, so they are
175+
* never offered again. See `lib/suggest.ts`.
176+
*/
177+
dismissedSuggestions: string[];
173178
}
174179

175180
export type SearchEngineId = 'google' | 'bing' | 'duckduckgo';
@@ -333,6 +338,7 @@ export const DEFAULT_SETTINGS: Settings = {
333338
googleAccount: 0,
334339
interceptStopList: [...DEFAULT_STOP_LIST],
335340
dispatchToast: false,
341+
dismissedSuggestions: [],
336342
};
337343

338344
export const DEFAULT_OVERRIDES: Overrides = {

0 commit comments

Comments
 (0)