Skip to content

Commit 1ed5ed4

Browse files
ion05claude
andcommitted
Rank frequently visited sites into shortcut suggestions.
Adds the pure ranking in src/lib/suggest.ts, the optional history permission and its reader in src/lib/history.ts, and a persisted settings.dismissedSuggestions list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1d4e585 commit 1ed5ed4

8 files changed

Lines changed: 299 additions & 0 deletions

File tree

‎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: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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({ alias, url: site.origin, name: siteName(site.title, host), host, score: site.score });
89+
}
90+
return out;
91+
}
92+
93+
/** The `#new?prefill=` text `parsePrefill` reads back: keyword, URL, name. */
94+
export function prefillFor(s: Suggestion): string {
95+
return `${s.alias} ${s.url} ${s.name}`;
96+
}
97+
98+
function hostOf(url: string): string {
99+
try {
100+
return bareHost(new URL(url).hostname);
101+
} catch {
102+
return '';
103+
}
104+
}
105+
106+
function bareHost(host: string): string {
107+
return host.toLowerCase().replace(/^www\./, '');
108+
}
109+
110+
function isPublicHost(host: string): boolean {
111+
if (!host.includes('.') || host.endsWith('.local') || host.endsWith('.localhost')) return false;
112+
// An IPv4 address or a bracketed IPv6 one names a machine, not a site.
113+
return !/^[\d.]+$/.test(host) && !host.startsWith('[');
114+
}
115+
116+
/**
117+
* The label a person would call the site by: `linear.app` → `linear`,
118+
* `mail.proton.me` → `proton`, `bbc.co.uk` → `bbc`. Then the subdomain
119+
* (`docs.google.com` → `docs`), then a prefix, then a numbered one.
120+
*/
121+
function pickAlias(host: string, taken: Set<string>): string {
122+
const main = mainLabel(host);
123+
const labels = host.split('.');
124+
const candidates = [main, labels[0]!, main.slice(0, 2), main.slice(0, 3)];
125+
for (let n = 2; n < 10; n++) candidates.push(`${main}${n}`);
126+
for (const candidate of candidates) {
127+
const check = validateAlias(candidate.replace(/[^a-z0-9-]/g, ''));
128+
if (check.ok && check.alias.length > 1 && !taken.has(check.alias)) return check.alias;
129+
}
130+
return '';
131+
}
132+
133+
function mainLabel(host: string): string {
134+
const labels = host.split('.');
135+
// ponytail: no public-suffix list. Two short trailing labels (co.uk, com.au)
136+
// are read as one suffix; a rarer shape just gets a less obvious keyword.
137+
const suffix =
138+
labels.length > 2 && labels.at(-1)!.length <= 3 && labels.at(-2)!.length <= 3 ? 2 : 1;
139+
return labels[labels.length - suffix - 1] ?? labels[0]!;
140+
}
141+
142+
/** `Linear – Plan and build products` → `Linear`; no title → `Linear` off the host. */
143+
function siteName(title: string, host: string): string {
144+
const lead = title.split(/\s+[|\-–—·:]\s+/)[0]?.trim() ?? '';
145+
if (lead && lead.length <= 40) return lead;
146+
const label = mainLabel(host);
147+
return label.charAt(0).toUpperCase() + label.slice(1);
148+
}

‎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 = {

‎tests/manifest.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,11 @@ describe('manifest', () => {
3636
// store has to re-review, so widening this is a deliberate edit here first.
3737
expect([...MANIFEST.host_permissions].sort()).toEqual(origins);
3838
});
39+
40+
it('asks for history only when the user opts in to suggestions', () => {
41+
// A new REQUIRED permission makes Chrome disable the extension for every
42+
// existing user until they accept it on chrome://extensions.
43+
expect(MANIFEST.permissions).not.toContain('history');
44+
expect(MANIFEST.optional_permissions).toEqual(['history']);
45+
});
3946
});

‎tests/storage.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const STATE: StoredState = {
3636
googleAccount: 2,
3737
interceptStopList: ['new', 'r'],
3838
dispatchToast: true,
39+
dismissedSuggestions: ['pitchfork.com'],
3940
},
4041
};
4142

‎tests/suggest.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { BUILTIN_COMMANDS } from '../src/lib/commands';
3+
import { parsePrefill } from '../src/lib/draft';
4+
import { mergeCommands } from '../src/lib/resolve';
5+
import { prefillFor, suggestShortcuts } from '../src/lib/suggest';
6+
import { DEFAULT_OVERRIDES } from '../src/lib/types';
7+
import { validateAlias } from '../src/lib/validate';
8+
9+
const commands = mergeCommands(BUILTIN_COMMANDS, DEFAULT_OVERRIDES);
10+
11+
describe('suggestShortcuts', () => {
12+
it('ranks sites by visits, typed visits counting triple, one per host', () => {
13+
const out = suggestShortcuts(
14+
[
15+
{ url: 'https://pitchfork.com/reviews/1', visitCount: 6, title: 'Issue 1' },
16+
{ url: 'https://pitchfork.com/', visitCount: 10, title: 'Pitchfork – Music reviews' },
17+
{ url: 'https://www.kagi.com/settings', visitCount: 3, typedCount: 5 },
18+
{ url: 'https://rarely.example/', visitCount: 2 },
19+
],
20+
commands,
21+
[],
22+
);
23+
expect(out.map((s) => [s.host, s.url])).toEqual([
24+
['kagi.com', 'https://www.kagi.com/'],
25+
['pitchfork.com', 'https://pitchfork.com/'],
26+
]);
27+
expect(out[1]!.name).toBe('Pitchfork');
28+
});
29+
30+
it('skips what a shortcut already reaches, what was dismissed, and non-sites', () => {
31+
const pages = [
32+
'https://github.com/facebook/react',
33+
'https://www.google.com/search?q=x',
34+
'https://notion.so/page',
35+
'http://localhost:3000/',
36+
'http://192.168.1.1/',
37+
'chrome://settings/',
38+
].map((url) => ({ url, visitCount: 50 }));
39+
expect(suggestShortcuts(pages, commands, ['notion.so'])).toEqual([]);
40+
});
41+
42+
it('falls back to another keyword when the obvious one is taken', () => {
43+
const [s] = suggestShortcuts(
44+
[{ url: 'https://gh.example.com/', visitCount: 9 }],
45+
commands,
46+
[],
47+
);
48+
expect(s!.alias).not.toBe('gh');
49+
expect(validateAlias(s!.alias).ok).toBe(true);
50+
expect(commands.some((c) => c.keys.includes(s!.alias))).toBe(false);
51+
});
52+
53+
it('opens the New shortcut form with the keyword and the site filled in', () => {
54+
const [s] = suggestShortcuts([{ url: 'https://pitchfork.com/x', visitCount: 9 }], commands, []);
55+
const draft = parsePrefill(prefillFor(s!));
56+
expect(draft.keys).toBe('pitchfork');
57+
expect(draft.url).toBe('https://pitchfork.com/');
58+
});
59+
});

0 commit comments

Comments
 (0)