Repository navigation
Expand file tree
/
Copy pathtypes.ts
More file actions
392 lines (367 loc) · 13.5 KB
/
Copy pathtypes.ts
File metadata and controls
392 lines (367 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
/**
* Shared contract for the whole extension.
*
* Every other module builds against these types. Nothing in here may import
* `chrome.*` or touch the DOM: it is imported by the pure resolver, the
* service worker, all three UI surfaces, and the tests.
*/
/**
* The sections this build ships. A CLOSED list, and the only thing a category
* pick may name: a user section holds no builtins, so it can never be a pack.
* User sections live in `Overrides.sections` and are open strings.
*/
export type Category =
| 'ai'
| 'search'
| 'dev'
| 'google'
| 'microsoft'
| 'purdue'
| 'social'
| 'productivity'
| 'meta'
| 'custom';
export const CATEGORIES: Category[] = [
'ai',
'search',
'dev',
'google',
'microsoft',
'purdue',
'social',
'productivity',
'meta',
'custom',
];
export const CATEGORY_LABELS: Record<Category, string> = {
ai: 'AI',
search: 'Search',
dev: 'Developer',
google: 'Google',
microsoft: 'Microsoft',
purdue: 'Purdue',
social: 'Social',
productivity: 'Productivity',
meta: 'BunnyLol',
custom: 'My shortcuts',
};
/**
* Identifies a "smart" argument handler in `handlers.ts`. A command with a
* handler bypasses plain `{q}` substitution: `resolve()` hands the handler the
* raw arguments, and the handler decides whether `searchUrl` is consulted. Most
* ignore it; the multi-tenant handlers (brightspace, gradescope) and the
* slot-shaped ones degrade their words through it, so it is a live, editable
* field on those rows rather than dead weight.
*/
export type HandlerId =
| 'github'
| 'githubPulls'
| 'githubIssues'
| 'githubGist'
| 'reddit'
| 'npm'
| 'gmail'
| 'gdrive'
| 'gcal'
| 'googleApp'
| 'outlook'
| 'onedrive'
| 'teams'
| 'ai'
| 'brightspace'
| 'gradescope'
| 'youtube'
| 'meta'
| 'zoom'
| 'meet'
| 'tracking'
| 'track'
| 'instagram'
| 'whatsapp'
| 'word';
export interface Command {
/**
* Stable identity, independent of the aliases. A shipped command omits it and
* is identified by its SHIPPED `keys[0]`, which never moves because the
* registry is code; a user-created one carries a generated `u:`-prefixed id
* that survives every key edit. `mergeCommands` and the storage boundary
* stamp the resolved value onto everything they emit, so the override maps,
* the browse rows and the resolver all key off one string. Never authored in
* `commands.ts`, never user-editable.
*/
id?: string;
/** Aliases. `keys[0]` is canonical, and for a shipped command it is the `id`. */
keys: string[];
name: string;
description: string;
/** Where a bare invocation (no arguments) goes. */
url: string;
/** Where `<key> <args>` goes. `{q}` is replaced with URI-encoded arguments. */
searchUrl?: string;
/** Opt into a smart handler instead of plain `{q}` substitution. */
handler?: HandlerId;
/**
* An open section id: a builtin `Category`, or the id of a `Section` in
* `Overrides.sections`. Resolved for display by `sectionLabel`; an id that
* names neither is coerced to `FALLBACK_SECTION` on the next save.
*/
category: string;
builtin: boolean;
/** Shown in the UI, e.g. "gh facebook/react -> github.com/facebook/react". */
example?: string;
/**
* Stable identity for handlers that must not depend on the live alias.
* `keys[0]` changes when the user rebinds a builtin, so the `ai` handler
* dispatches on this instead. Set on builtins only.
*/
provider?: string;
}
/**
* A registry row. The shipped commands keep the CLOSED category union and a
* literal `builtin: true` even though `Command` is open on both, so a typo'd
* category in `commands.ts` is still a compile error and
* `tests/commands.test.ts` keeps a real assertion rather than a tautology.
* Assignable to `Command` everywhere.
*/
export type BuiltinCommand = Command & { category: Category; builtin: true };
/** A provider whose web UI accepts a prompt via URL parameter. */
export interface AiProvider {
id: string;
label: string;
/** URL template containing `{q}`. Editable from the options page. */
template: string;
/** Where a bare invocation goes. */
home: string;
}
export interface Settings {
/** Used by `gh me`, `pr`, `iss`. */
githubUser: string;
/** Where an unrecognized query goes. Template containing `{q}`. */
defaultEngine: string;
/** Which search engines DNR intercepts. Values are `SearchEngineId`s. */
interceptEngines: SearchEngineId[];
/** Overrides for `AI_PROVIDERS` templates, keyed by provider id. */
aiTemplates: Record<string, string>;
/** Google Workspace account index, e.g. 0 or 1, for /u/N/ URLs. */
googleAccount: number;
/**
* Aliases the user has EXEMPTED from address-bar interception. Empty by
* default; see `DEFAULT_STOP_LIST`.
*/
interceptStopList: string[];
/**
* Hold the dispatch page on a confirmation naming the command that fired,
* with the destination, a button that opens it and a link to search for what
* was typed instead. Off by default, because the ordinary dispatch must not
* ask a question. Nothing auto-navigates: see `confirmOpen` in go.ts.
*
* NOT a toast any more, and the name is kept anyway. It was one once, on a
* 1.2s timer; the checkbox now reads "Confirm before opening a shortcut" and
* go.ts calls the thing it gates `confirmOpen`. Renaming the stored field
* would make every export written so far, and every profile in storage, read
* 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';
export interface SearchEngine {
id: SearchEngineId;
label: string;
/** Host pattern for `host_permissions` / DNR, e.g. "www.google.com". */
host: string;
/** Regex source matching this engine's results URL up to the `q=` value. */
urlPrefixPattern: string;
}
/**
* The fields of a shipped shortcut the user is allowed to change, as a diff
* against the shipped definition rather than a copy of it, so a corrected URL
* in a later build still reaches someone who only renamed the command.
*
* Absent means "inherit"; `null` on the two optional fields means "cleared",
* which is a different instruction and cannot be said with `undefined`.
*
* There is deliberately no `handler`, `provider`, `builtin` or `id` here.
* Those select behaviour and identity, and an import file is untrusted input:
* `applyEdit` copies this type field by field rather than spreading, so a
* hand-edited edit object has no path into any of them (invariant 16).
*/
export interface ShortcutEdit {
keys?: string[];
name?: string;
description?: string;
url?: string;
searchUrl?: string | null;
category?: string;
example?: string | null;
}
/**
* A group in the browse list. An entry whose `id` names a builtin category is
* not a collision: it is how a shipped category gets renamed.
*/
export interface Section {
id: string;
label: string;
}
/**
* Where a command whose category names no section this build knows about ends
* up. "My shortcuts" is the one group that is always there, so it is the only
* safe destination for an orphan.
*/
export const FALLBACK_SECTION = 'custom';
/** The user's customization layer. Builtins are never mutated in place. */
export interface Overrides {
/** Ids of shortcuts the user turned off. Shipped or custom. */
disabled: string[];
/**
* Ids of SHIPPED shortcuts the user deleted, kept so they stay restorable. A
* custom command is deleted by removing it from `custom`.
*/
deleted: string[];
/**
* Shortcut id -> the fields the user changed. Shipped shortcuts only: a
* custom command has nothing to diff against and is edited in place.
*/
edits: Record<string, ShortcutEdit>;
/** User-created sections, plus renames of shipped ones. */
sections: Section[];
/** User-created commands. Always `builtin: false`, always a `u:` id. */
custom: Command[];
/**
* The builtin categories the user picked during onboarding. `null` means they
* never saw the picker: every category counts as enabled and a builtin added
* later arrives on. No resolution path reads it: the resolver reads
* `disabled` and nothing else, so there is exactly one exclusion axis and a
* pick is projected onto it at write time by `applyCategoryPick`.
*
* `[]` is a real answer and not the same as `null`: it says the user
* unchecked every pack.
*/
enabledCategories: string[] | null;
/**
* Shortcut ids this profile has already been offered, so an update can tell a
* builtin added since the last version from one the user deliberately turned
* off.
*/
seenBuiltins: string[];
}
export interface StoredState {
overrides: Overrides;
settings: Settings;
}
/** What the resolver produces. Never throws; always yields a navigable URL. */
export interface ResolveResult {
url: string;
/** The matched command, or null when we fell through to the default engine. */
command: Command | null;
/** Raw arguments after the keyword, trimmed. Empty string when bare. */
args: string;
/** True when no command matched and we used `settings.defaultEngine`. */
fallback: boolean;
}
/**
* `keyword` is the alias the user actually typed, which a handler needs when
* its degrade is a plain search: reproducing the query the alias intercepted
* ("lh surge meaning") requires the keyword, and `cmd.keys[0]` is the canonical
* alias rather than the typed one. Optional so a handler called directly still
* type-checks, as in the tests or with an imported command.
*/
export type HandlerFn = (
args: string,
cmd: Command,
settings: Settings,
keyword?: string,
) => string;
/**
* The user's EXEMPTION list: aliases they have asked BunnyLol to leave out of
* address-bar interception.
*
* Empty on purpose. BunnyLol follows true bunnylol semantics: if the first
* word of an address-bar query is a registered keyword, it IS a command, every
* time. `c programming tutorial` opens Claude and `pr firms in new york` opens
* your pull requests, and that is the contract rather than a bug: a blocklist
* of "words that look like English" was an endless tail, and every entry on it
* made the address bar less predictable rather than more.
*
* What makes that liveable is the escape hatch (`FORCE_SEARCH_PREFIXES`), not
* a curated list. This list stays because one user in ten will keep tripping
* over one specific keyword, "I search for 'maps of X' constantly", and the
* options page lets them exempt exactly that alias. An exempted alias loses
* address-bar interception and nothing else: it still resolves from the `bl`
* omnibox keyword and the toolbar popup.
*/
export const DEFAULT_STOP_LIST: string[] = [];
/**
* A leading one of these forces a plain default-engine search of whatever
* follows: `\\gh foo` and `=gh foo` both search for "gh foo" rather than
* opening GitHub.
*
* TWO of them, defined here once and consumed by both the resolver and the DNR
* rule builder. `\\` is the traditional bunnylol escape but needs AltGr on
* several European layouts and is the character most likely to be mangled on
* the way into a URL; `=` is one unshifted keystroke everywhere and is never
* the first character of a real search.
*
* ORDER MATTERS ONLY FOR DOCS: matching tries each in turn, and no prefix here
* may be a prefix of another.
*/
export const FORCE_SEARCH_PREFIXES: string[] = ['\\', '='];
export const DEFAULT_SETTINGS: Settings = {
githubUser: '',
defaultEngine: 'https://www.google.com/search?q={q}',
interceptEngines: ['google', 'bing', 'duckduckgo'],
aiTemplates: {},
googleAccount: 0,
interceptStopList: [...DEFAULT_STOP_LIST],
dispatchToast: false,
dismissedSuggestions: [],
};
export const DEFAULT_OVERRIDES: Overrides = {
disabled: [],
deleted: [],
edits: {},
sections: [],
custom: [],
enabledCategories: null,
seenBuiltins: [],
};
export const STORAGE_KEY = 'bunnylol.state.v1';
/** Messages the UI surfaces send to the service worker. */
export type BgMessage =
{ type: 'resyncRules' } | { type: 'getRuleStatus' } | { type: 'getExtensionId' };
export interface RuleStatus {
/** Dynamic rules Chrome actually holds, read back after the sync. */
registered: number;
/**
* Aliases the registered rules really do intercept, on every selected engine.
* NOT the number of aliases that were eligible: a shard Chrome refuses to
* compile, or one past the rule budget, costs coverage, and the whole point of
* this field is to say so.
*/
keywords: number;
/** Aliases the user exempted through `settings.interceptStopList`. */
suppressed: number;
/** Eligible aliases that ended up with no rule: `keywords + dropped` is the eligible total. */
dropped: number;
/**
* Set only when the sync itself failed and interception is not working.
*
* Split from `warning` because the two need different words and a different
* colour: partial coverage used to be reported here, which painted the fatal
* red state over a working extension and left the amber one unreachable.
*/
error: string | null;
/** Set when the sync succeeded but could not cover every keyword. */
warning: string | null;
extensionId: string;
}
/**
* Appended to a BunnyLol-generated fallback search so our own DNR rules skip
* it. Without this, a `FORCE_SEARCH_PREFIXES` escape is redirected straight
* back into the dispatch page by the very rule it is meant to bypass.
*/
export const PASSTHROUGH_PARAM = 'blpass';