@arraypress/extension-kit 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -4
- package/package.json +2 -2
- package/src/ai/model.d.ts +317 -0
- package/src/ai/model.js +312 -0
- package/src/build/vendor.js +1 -1
- package/src/content/slices.d.ts +63 -0
- package/src/content/slices.js +80 -0
- package/src/content/watch.d.ts +71 -0
- package/src/content/watch.js +95 -0
- package/src/index.d.ts +35 -4
- package/src/index.js +25 -4
- package/src/scripting/registration.d.ts +36 -0
- package/src/scripting/registration.js +54 -0
- package/src/styles/chrome-ui.css +11 -0
- package/src/testing/downloads.d.ts +41 -0
- package/src/testing/downloads.js +70 -0
- package/src/testing/e2e.d.ts +15 -0
- package/src/testing/e2e.js +26 -1
- package/src/testing/fake-model.d.ts +87 -0
- package/src/testing/fake-model.js +142 -0
- package/src/testing/fake-storage.d.ts +2 -0
- package/src/testing/fake-storage.js +15 -3
- package/src/testing/index.d.ts +19 -5
- package/src/testing/index.js +14 -5
- package/src/testing/live.d.ts +146 -0
- package/src/testing/live.js +151 -0
- package/src/testing/messages.d.ts +33 -0
- package/src/testing/messages.js +51 -0
- package/src/testing/server.d.ts +23 -3
- package/src/testing/server.js +36 -6
- package/src/{testing → time}/pool.d.ts +4 -3
- package/src/{testing → time}/pool.js +4 -3
- package/src/ui/model-row.d.ts +111 -0
- package/src/ui/model-row.js +108 -0
- package/src/ui/text-dialog.d.ts +44 -7
- package/src/ui/text-dialog.js +54 -16
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's message lookup for an extension's unit tests: its real `_locales`
|
|
3
|
+
* files, a locale's messages over `en`'s, named placeholders ("$SITE$" →
|
|
4
|
+
* `{site: {content: '$1'}}`) filled from substitutions as
|
|
5
|
+
* `chrome.i18n.getMessage` fills them. Node only. From the copies in Better
|
|
6
|
+
* Prices, Better Downloads, Better Saves and Clean Links (`test/helpers/i18n.js`).
|
|
7
|
+
*
|
|
8
|
+
* @module testing/messages
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One locale's messages.json.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Record<string, {message: string, description?: string, placeholders?: Record<string, {content: string}>}>} Messages
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read a locale's messages.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} extensionDir - The extension's folder (with `_locales/`).
|
|
23
|
+
* @param {string} locale - "en" or "en_GB".
|
|
24
|
+
* @returns {Messages} Its messages.
|
|
25
|
+
* @example
|
|
26
|
+
* readMessages('extension', 'en_GB').optionsColour.message;
|
|
27
|
+
*/
|
|
28
|
+
export function readMessages(extensionDir, locale) {
|
|
29
|
+
return JSON.parse(readFileSync(path.join(extensionDir, '_locales', locale, 'messages.json'), 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `chrome.i18n.getMessage` over a locale's real messages ('' for a missing key, as Chrome gives).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} extensionDir - The extension's folder.
|
|
36
|
+
* @param {string} [locale] - Default "en"; another locale is read over en.
|
|
37
|
+
* @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
|
|
38
|
+
* @example
|
|
39
|
+
* const t = messageLookup('extension');
|
|
40
|
+
* t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
|
|
41
|
+
*/
|
|
42
|
+
export function messageLookup(extensionDir, locale = 'en') {
|
|
43
|
+
const messages = { ...readMessages(extensionDir, 'en'), ...(locale === 'en' ? {} : readMessages(extensionDir, locale)) };
|
|
44
|
+
return (key, substitutions = []) => {
|
|
45
|
+
const entry = messages[key];
|
|
46
|
+
if (!entry) return '';
|
|
47
|
+
const subs = [substitutions].flat();
|
|
48
|
+
return entry.message.replace(/\$([A-Za-z_]+)\$/g, (_, name) => (entry.placeholders?.[name.toLowerCase()]?.content ?? '')
|
|
49
|
+
.replace(/\$(\d)/g, (/** @type {string} */ __, /** @type {string} */ n) => subs[Number(n) - 1] ?? ''));
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/testing/server.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A local web server for end-to-end test pages: a site for the content
|
|
3
3
|
* script to run on, answering each path from a table (or a function), on
|
|
4
|
-
* 127.0.0.1 so a test build's host permission can cover it
|
|
4
|
+
* 127.0.0.1 so a test build's host permission can cover it; over HTTPS too,
|
|
5
|
+
* with a throwaway certificate.
|
|
5
6
|
*
|
|
6
7
|
* @module testing/server
|
|
7
8
|
*/
|
|
@@ -31,17 +32,36 @@ export type TestServer = {
|
|
|
31
32
|
*/
|
|
32
33
|
close: () => Promise<void>;
|
|
33
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* A self-signed certificate for 127.0.0.1 and localhost, made by `openssl`, valid for a day: for
|
|
37
|
+
* {@link serve}'s `tls`, with Chromium started with `--ignore-certificate-errors` (a site whose
|
|
38
|
+
* name Chrome only reaches over HTTPS, as every HSTS-preloaded one, mapped to the test server with
|
|
39
|
+
* `--host-resolver-rules`).
|
|
40
|
+
*
|
|
41
|
+
* @returns {{key: string, cert: string}} The key and certificate, PEM.
|
|
42
|
+
* @example
|
|
43
|
+
* const site = await serve(routes, { tls: testCertificate() });
|
|
44
|
+
*/
|
|
45
|
+
export declare function testCertificate(): {
|
|
46
|
+
key: string;
|
|
47
|
+
cert: string;
|
|
48
|
+
};
|
|
34
49
|
/**
|
|
35
50
|
* Serve `routes` on 127.0.0.1.
|
|
36
51
|
*
|
|
37
52
|
* @param {Routes} routes - Path → page, or a function of the path.
|
|
38
|
-
* @param {{port?: number}} [options] - `port`: a fixed port (default: any
|
|
53
|
+
* @param {{port?: number, tls?: {key: string, cert: string}}} [options] - `port`: a fixed port (default: any
|
|
54
|
+
* free one); `tls`: serve HTTPS with this key and certificate ({@link testCertificate}).
|
|
39
55
|
* @returns {Promise<TestServer>} The listening server.
|
|
40
56
|
* @example
|
|
41
57
|
* const site = await serve({'/': '<!doctype html><textarea></textarea>'});
|
|
42
58
|
* await page.goto(`${site.origin}/`);
|
|
43
59
|
* await site.close();
|
|
44
60
|
*/
|
|
45
|
-
export declare function serve(routes: Routes, { port }?: {
|
|
61
|
+
export declare function serve(routes: Routes, { port, tls }?: {
|
|
46
62
|
port?: number;
|
|
63
|
+
tls?: {
|
|
64
|
+
key: string;
|
|
65
|
+
cert: string;
|
|
66
|
+
};
|
|
47
67
|
}): Promise<TestServer>;
|
package/src/testing/server.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A local web server for end-to-end test pages: a site for the content
|
|
3
3
|
* script to run on, answering each path from a table (or a function), on
|
|
4
|
-
* 127.0.0.1 so a test build's host permission can cover it
|
|
4
|
+
* 127.0.0.1 so a test build's host permission can cover it; over HTTPS too,
|
|
5
|
+
* with a throwaway certificate.
|
|
5
6
|
*
|
|
6
7
|
* @module testing/server
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
12
|
import { createServer } from 'node:http';
|
|
13
|
+
import { createServer as createSecureServer } from 'node:https';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
10
16
|
|
|
11
17
|
/** @import { IncomingMessage, Server } from 'node:http' */
|
|
12
18
|
|
|
@@ -52,19 +58,42 @@ async function replyFor(routes, pathname, request) {
|
|
|
52
58
|
return Object.hasOwn(table, pathname) ? table[pathname] : undefined;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A self-signed certificate for 127.0.0.1 and localhost, made by `openssl`, valid for a day: for
|
|
63
|
+
* {@link serve}'s `tls`, with Chromium started with `--ignore-certificate-errors` (a site whose
|
|
64
|
+
* name Chrome only reaches over HTTPS, as every HSTS-preloaded one, mapped to the test server with
|
|
65
|
+
* `--host-resolver-rules`).
|
|
66
|
+
*
|
|
67
|
+
* @returns {{key: string, cert: string}} The key and certificate, PEM.
|
|
68
|
+
* @example
|
|
69
|
+
* const site = await serve(routes, { tls: testCertificate() });
|
|
70
|
+
*/
|
|
71
|
+
export function testCertificate() {
|
|
72
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'kit-tls-'));
|
|
73
|
+
try {
|
|
74
|
+
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost',
|
|
75
|
+
'-keyout', path.join(dir, 'key.pem'), '-out', path.join(dir, 'cert.pem')], { stdio: 'ignore' });
|
|
76
|
+
return { key: readFileSync(path.join(dir, 'key.pem'), 'utf8'), cert: readFileSync(path.join(dir, 'cert.pem'), 'utf8') };
|
|
77
|
+
} finally {
|
|
78
|
+
rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
/**
|
|
56
83
|
* Serve `routes` on 127.0.0.1.
|
|
57
84
|
*
|
|
58
85
|
* @param {Routes} routes - Path → page, or a function of the path.
|
|
59
|
-
* @param {{port?: number}} [options] - `port`: a fixed port (default: any
|
|
86
|
+
* @param {{port?: number, tls?: {key: string, cert: string}}} [options] - `port`: a fixed port (default: any
|
|
87
|
+
* free one); `tls`: serve HTTPS with this key and certificate ({@link testCertificate}).
|
|
60
88
|
* @returns {Promise<TestServer>} The listening server.
|
|
61
89
|
* @example
|
|
62
90
|
* const site = await serve({'/': '<!doctype html><textarea></textarea>'});
|
|
63
91
|
* await page.goto(`${site.origin}/`);
|
|
64
92
|
* await site.close();
|
|
65
93
|
*/
|
|
66
|
-
export async function serve(routes, { port = 0 } = {}) {
|
|
67
|
-
|
|
94
|
+
export async function serve(routes, { port = 0, tls } = {}) {
|
|
95
|
+
/** @type {import('node:http').RequestListener} */
|
|
96
|
+
const handle = async (request, response) => {
|
|
68
97
|
const { pathname } = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
69
98
|
const reply = await replyFor(routes, pathname, request);
|
|
70
99
|
if (reply === undefined) {
|
|
@@ -75,13 +104,14 @@ export async function serve(routes, { port = 0 } = {}) {
|
|
|
75
104
|
const { body, type = HTML, status = 200, headers = {} } = typeof reply === 'string' || reply instanceof Uint8Array ? { body: reply } : reply;
|
|
76
105
|
response.writeHead(status, { 'Content-Type': type, ...headers });
|
|
77
106
|
response.end(body);
|
|
78
|
-
}
|
|
107
|
+
};
|
|
108
|
+
const server = tls ? createSecureServer(tls, handle) : createServer(handle);
|
|
79
109
|
await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(undefined)));
|
|
80
110
|
const address = /** @type {import('node:net').AddressInfo} */ (server.address());
|
|
81
111
|
return {
|
|
82
112
|
server,
|
|
83
113
|
port: address.port,
|
|
84
|
-
origin:
|
|
114
|
+
origin: `${tls ? 'https' : 'http'}://127.0.0.1:${address.port}`,
|
|
85
115
|
close() {
|
|
86
116
|
server.closeAllConnections();
|
|
87
117
|
return new Promise((resolve) => server.close(() => resolve()));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Working through a list a few at a time:
|
|
3
|
-
* in one of a few browsers.
|
|
2
|
+
* Working through a list a few at a time: files fetched by an extension page,
|
|
3
|
+
* or a live check of many sites, each in one of a few browsers. Runs
|
|
4
|
+
* anywhere (worker, page, Node).
|
|
4
5
|
*
|
|
5
|
-
* @module
|
|
6
|
+
* @module time/pool
|
|
6
7
|
*/
|
|
7
8
|
/**
|
|
8
9
|
* Run `work` on every item, at most `limit` at once, each slot taking the
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Working through a list a few at a time:
|
|
3
|
-
* in one of a few browsers.
|
|
2
|
+
* Working through a list a few at a time: files fetched by an extension page,
|
|
3
|
+
* or a live check of many sites, each in one of a few browsers. Runs
|
|
4
|
+
* anywhere (worker, page, Node).
|
|
4
5
|
*
|
|
5
|
-
* @module
|
|
6
|
+
* @module time/pool
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings row “Chrome’s built-in AI”: how the on-device model stands,
|
|
3
|
+
* what that means, and Download when it can be had, with Chrome's progress.
|
|
4
|
+
* The download starts from the click (Chrome only downloads from a user
|
|
5
|
+
* gesture): nothing is awaited in the click before `create()`. The words are
|
|
6
|
+
* the page's messages under {@link MODEL_MESSAGES}' keys.
|
|
7
|
+
*
|
|
8
|
+
* Markup it fills (the page's, so it sits in any card):
|
|
9
|
+
*
|
|
10
|
+
* ```html
|
|
11
|
+
* <div class="row two-line">
|
|
12
|
+
* <span class="label">
|
|
13
|
+
* <span data-i18n="optionsModel">Chrome’s built-in AI</span>
|
|
14
|
+
* <span class="secondary"><span id="model-status"></span> <span id="model-help" hidden></span></span>
|
|
15
|
+
* <progress id="model-progress" max="1" value="0" hidden></progress>
|
|
16
|
+
* </span>
|
|
17
|
+
* <button id="model-download" type="button" data-i18n="optionsModelDownload" hidden>Download</button>
|
|
18
|
+
* </div>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @module ui/model-row
|
|
22
|
+
*/
|
|
23
|
+
import type { Availability, LanguageModelOptions, LanguageModelStatic, ModelStatus } from '../ai/model.js';
|
|
24
|
+
export type ModelRowParts = {
|
|
25
|
+
/**
|
|
26
|
+
* The state's words.
|
|
27
|
+
*/
|
|
28
|
+
status: HTMLElement;
|
|
29
|
+
/**
|
|
30
|
+
* What it means, or why a download failed.
|
|
31
|
+
*/
|
|
32
|
+
help: HTMLElement;
|
|
33
|
+
/**
|
|
34
|
+
* Download.
|
|
35
|
+
*/
|
|
36
|
+
download: HTMLButtonElement;
|
|
37
|
+
/**
|
|
38
|
+
* The download's progress, 0–1.
|
|
39
|
+
*/
|
|
40
|
+
progress: HTMLProgressElement;
|
|
41
|
+
};
|
|
42
|
+
export type ModelRowOptions = {
|
|
43
|
+
/**
|
|
44
|
+
* What sessions are made with (a download is for these).
|
|
45
|
+
*/
|
|
46
|
+
options: LanguageModelOptions;
|
|
47
|
+
/**
|
|
48
|
+
* The model's state as the code that uses it sees it (the
|
|
49
|
+
* worker's, asked by message, when the worker uses it).
|
|
50
|
+
*/
|
|
51
|
+
state: () => Promise<Availability>;
|
|
52
|
+
/**
|
|
53
|
+
* The page's messages.
|
|
54
|
+
*/
|
|
55
|
+
t: (key: string, substitutions?: string | string[]) => string;
|
|
56
|
+
/**
|
|
57
|
+
* The Prompt API in this page. Default Chrome's.
|
|
58
|
+
*/
|
|
59
|
+
model?: () => LanguageModelStatic | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Hears each state shown
|
|
62
|
+
* (to turn the switches that need the model on or off).
|
|
63
|
+
*/
|
|
64
|
+
onShown?: (status: ModelStatus, availability: Availability) => void;
|
|
65
|
+
};
|
|
66
|
+
export type ModelRow = {
|
|
67
|
+
/**
|
|
68
|
+
* Ask the state again and show it.
|
|
69
|
+
*/
|
|
70
|
+
refresh: () => Promise<Availability>;
|
|
71
|
+
/**
|
|
72
|
+
* The state last shown (null before the first).
|
|
73
|
+
*/
|
|
74
|
+
current: () => Availability | null;
|
|
75
|
+
};
|
|
76
|
+
/** @import { Availability, LanguageModelOptions, LanguageModelStatic, ModelStatus } from '../ai/model.js' */
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {object} ModelRowParts
|
|
79
|
+
* @property {HTMLElement} status The state's words.
|
|
80
|
+
* @property {HTMLElement} help What it means, or why a download failed.
|
|
81
|
+
* @property {HTMLButtonElement} download Download.
|
|
82
|
+
* @property {HTMLProgressElement} progress The download's progress, 0–1.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {object} ModelRowOptions
|
|
86
|
+
* @property {LanguageModelOptions} options What sessions are made with (a download is for these).
|
|
87
|
+
* @property {() => Promise<Availability>} state The model's state as the code that uses it sees it (the
|
|
88
|
+
* worker's, asked by message, when the worker uses it).
|
|
89
|
+
* @property {(key: string, substitutions?: string | string[]) => string} t The page's messages.
|
|
90
|
+
* @property {() => LanguageModelStatic | undefined} [model] The Prompt API in this page. Default Chrome's.
|
|
91
|
+
* @property {(status: ModelStatus, availability: Availability) => void} [onShown] Hears each state shown
|
|
92
|
+
* (to turn the switches that need the model on or off).
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {object} ModelRow
|
|
96
|
+
* @property {() => Promise<Availability>} refresh Ask the state again and show it.
|
|
97
|
+
* @property {() => Availability | null} current The state last shown (null before the first).
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Wire the row up and show the model's state.
|
|
101
|
+
*
|
|
102
|
+
* @param {ModelRowParts} parts - The row's elements.
|
|
103
|
+
* @param {ModelRowOptions} setup - What to ask, and the words.
|
|
104
|
+
* @returns {ModelRow} The row.
|
|
105
|
+
* @example
|
|
106
|
+
* const row = createModelRow({ status: byId('model-status'), help: byId('model-help'),
|
|
107
|
+
* download: byId('model-download', HTMLButtonElement), progress: byId('model-progress', HTMLProgressElement) },
|
|
108
|
+
* { options: SESSION_OPTIONS, state: () => ask(MESSAGES.modelState), t, onShown: ({ state }) => { useAi.disabled = state === 'off'; } });
|
|
109
|
+
* await row.refresh();
|
|
110
|
+
*/
|
|
111
|
+
export declare function createModelRow({ status, help, download, progress }: ModelRowParts, { options, state, t, model, onShown }: ModelRowOptions): ModelRow;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings row “Chrome’s built-in AI”: how the on-device model stands,
|
|
3
|
+
* what that means, and Download when it can be had, with Chrome's progress.
|
|
4
|
+
* The download starts from the click (Chrome only downloads from a user
|
|
5
|
+
* gesture): nothing is awaited in the click before `create()`. The words are
|
|
6
|
+
* the page's messages under {@link MODEL_MESSAGES}' keys.
|
|
7
|
+
*
|
|
8
|
+
* Markup it fills (the page's, so it sits in any card):
|
|
9
|
+
*
|
|
10
|
+
* ```html
|
|
11
|
+
* <div class="row two-line">
|
|
12
|
+
* <span class="label">
|
|
13
|
+
* <span data-i18n="optionsModel">Chrome’s built-in AI</span>
|
|
14
|
+
* <span class="secondary"><span id="model-status"></span> <span id="model-help" hidden></span></span>
|
|
15
|
+
* <progress id="model-progress" max="1" value="0" hidden></progress>
|
|
16
|
+
* </span>
|
|
17
|
+
* <button id="model-download" type="button" data-i18n="optionsModelDownload" hidden>Download</button>
|
|
18
|
+
* </div>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @module ui/model-row
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { MODEL_MESSAGES, chromeLanguageModel, describeModel, downloadModel } from '../ai/model.js';
|
|
25
|
+
|
|
26
|
+
/** @import { Availability, LanguageModelOptions, LanguageModelStatic, ModelStatus } from '../ai/model.js' */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} ModelRowParts
|
|
30
|
+
* @property {HTMLElement} status The state's words.
|
|
31
|
+
* @property {HTMLElement} help What it means, or why a download failed.
|
|
32
|
+
* @property {HTMLButtonElement} download Download.
|
|
33
|
+
* @property {HTMLProgressElement} progress The download's progress, 0–1.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} ModelRowOptions
|
|
38
|
+
* @property {LanguageModelOptions} options What sessions are made with (a download is for these).
|
|
39
|
+
* @property {() => Promise<Availability>} state The model's state as the code that uses it sees it (the
|
|
40
|
+
* worker's, asked by message, when the worker uses it).
|
|
41
|
+
* @property {(key: string, substitutions?: string | string[]) => string} t The page's messages.
|
|
42
|
+
* @property {() => LanguageModelStatic | undefined} [model] The Prompt API in this page. Default Chrome's.
|
|
43
|
+
* @property {(status: ModelStatus, availability: Availability) => void} [onShown] Hears each state shown
|
|
44
|
+
* (to turn the switches that need the model on or off).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} ModelRow
|
|
49
|
+
* @property {() => Promise<Availability>} refresh Ask the state again and show it.
|
|
50
|
+
* @property {() => Availability | null} current The state last shown (null before the first).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire the row up and show the model's state.
|
|
55
|
+
*
|
|
56
|
+
* @param {ModelRowParts} parts - The row's elements.
|
|
57
|
+
* @param {ModelRowOptions} setup - What to ask, and the words.
|
|
58
|
+
* @returns {ModelRow} The row.
|
|
59
|
+
* @example
|
|
60
|
+
* const row = createModelRow({ status: byId('model-status'), help: byId('model-help'),
|
|
61
|
+
* download: byId('model-download', HTMLButtonElement), progress: byId('model-progress', HTMLProgressElement) },
|
|
62
|
+
* { options: SESSION_OPTIONS, state: () => ask(MESSAGES.modelState), t, onShown: ({ state }) => { useAi.disabled = state === 'off'; } });
|
|
63
|
+
* await row.refresh();
|
|
64
|
+
*/
|
|
65
|
+
export function createModelRow({ status, help, download, progress }, { options, state, t, model = chromeLanguageModel, onShown }) {
|
|
66
|
+
/** @type {Availability | null} */
|
|
67
|
+
let shown = null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Show a state, or why the last download failed.
|
|
71
|
+
*
|
|
72
|
+
* @param {Availability} availability - The model's.
|
|
73
|
+
* @param {string} [problem] - What went wrong.
|
|
74
|
+
*/
|
|
75
|
+
function show(availability, problem = '') {
|
|
76
|
+
const described = describeModel(availability);
|
|
77
|
+
shown = availability;
|
|
78
|
+
status.dataset.state = described.state;
|
|
79
|
+
status.textContent = t(described.status);
|
|
80
|
+
help.textContent = problem || (described.help ? t(described.help) : '');
|
|
81
|
+
help.hidden = !help.textContent;
|
|
82
|
+
download.hidden = !described.canDownload;
|
|
83
|
+
download.disabled = false;
|
|
84
|
+
progress.hidden = true;
|
|
85
|
+
onShown?.(described, availability);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
download.addEventListener('click', () => {
|
|
89
|
+
download.disabled = true;
|
|
90
|
+
progress.hidden = false;
|
|
91
|
+
progress.value = 0;
|
|
92
|
+
downloadModel(model(), options, {
|
|
93
|
+
onProgress: (loaded) => {
|
|
94
|
+
progress.value = loaded;
|
|
95
|
+
status.textContent = t(MODEL_MESSAGES.downloading, [String(Math.round(loaded * 100))]);
|
|
96
|
+
},
|
|
97
|
+
}).then(async () => show(await state()), () => show('downloadable', t(MODEL_MESSAGES.downloadFailed)));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
async refresh() {
|
|
102
|
+
const availability = await state();
|
|
103
|
+
show(availability);
|
|
104
|
+
return availability;
|
|
105
|
+
},
|
|
106
|
+
current: () => shown,
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/ui/text-dialog.d.ts
CHANGED
|
@@ -20,13 +20,33 @@ export type FieldRequest = {
|
|
|
20
20
|
*/
|
|
21
21
|
label: string;
|
|
22
22
|
/**
|
|
23
|
-
* The text to start with. Default ''.
|
|
23
|
+
* The text to start with. Default ''. A promise is text
|
|
24
|
+
* still being worked out (an on-device model's suggestion): the field waits, disabled, with
|
|
25
|
+
* `waiting` under it, and takes the text when it comes ('' if it fails).
|
|
24
26
|
*/
|
|
25
|
-
value?: string
|
|
27
|
+
value?: string | Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* What the field says while its `value` is worked out ("Finding words…").
|
|
30
|
+
*/
|
|
31
|
+
waiting?: string;
|
|
26
32
|
/**
|
|
27
33
|
* Shown while it's empty. Default none.
|
|
28
34
|
*/
|
|
29
35
|
placeholder?: string;
|
|
36
|
+
/**
|
|
37
|
+
* A `<textarea>` (cr-textarea) rather than one line: Enter starts a new
|
|
38
|
+
* line, and only the action button saves. Default false.
|
|
39
|
+
*/
|
|
40
|
+
multiline?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* A multi-line field's height in lines. Default 6.
|
|
43
|
+
*/
|
|
44
|
+
rows?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Help under the field (cr-input's
|
|
47
|
+
* description), or a function of its text, kept up as it's typed ("12 words"). Default none.
|
|
48
|
+
*/
|
|
49
|
+
hint?: string | ((text: string) => string);
|
|
30
50
|
/**
|
|
31
51
|
* Why the (non-blank) text can't be used,
|
|
32
52
|
* shown under the field; null when it can. Default: anything but blank.
|
|
@@ -71,8 +91,16 @@ export type FieldsRequest = DialogRequest & {
|
|
|
71
91
|
* @typedef {object} FieldRequest
|
|
72
92
|
* @property {string} name Its key in the answer ("folder"), and part of its id.
|
|
73
93
|
* @property {string} label Its label ("Folder").
|
|
74
|
-
* @property {string} [value] The text to start with. Default ''.
|
|
94
|
+
* @property {string | Promise<string>} [value] The text to start with. Default ''. A promise is text
|
|
95
|
+
* still being worked out (an on-device model's suggestion): the field waits, disabled, with
|
|
96
|
+
* `waiting` under it, and takes the text when it comes ('' if it fails).
|
|
97
|
+
* @property {string} [waiting] What the field says while its `value` is worked out ("Finding words…").
|
|
75
98
|
* @property {string} [placeholder] Shown while it's empty. Default none.
|
|
99
|
+
* @property {boolean} [multiline] A `<textarea>` (cr-textarea) rather than one line: Enter starts a new
|
|
100
|
+
* line, and only the action button saves. Default false.
|
|
101
|
+
* @property {number} [rows] A multi-line field's height in lines. Default 6.
|
|
102
|
+
* @property {string | ((text: string) => string)} [hint] Help under the field (cr-input's
|
|
103
|
+
* description), or a function of its text, kept up as it's typed ("12 words"). Default none.
|
|
76
104
|
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
77
105
|
* shown under the field; null when it can. Default: anything but blank.
|
|
78
106
|
* @property {number} [maxLength] Its `maxlength`. Default none.
|
|
@@ -120,13 +148,14 @@ export declare function checkText(text: string, validate?: (text: string) => str
|
|
|
120
148
|
*
|
|
121
149
|
* @param {string} name - The field's name.
|
|
122
150
|
* @param {boolean} only - Whether it's the dialog's only field.
|
|
123
|
-
* @returns {{input: string, error: string}} The ids.
|
|
151
|
+
* @returns {{input: string, error: string, hint: string}} The ids.
|
|
124
152
|
* @example
|
|
125
|
-
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
|
|
153
|
+
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error', hint: 'text-dialog-folder-hint'}
|
|
126
154
|
*/
|
|
127
155
|
export declare function fieldIds(name: string, only: boolean): {
|
|
128
156
|
input: string;
|
|
129
157
|
error: string;
|
|
158
|
+
hint: string;
|
|
130
159
|
};
|
|
131
160
|
export type Parts = {
|
|
132
161
|
/**
|
|
@@ -162,11 +191,19 @@ export type Field = {
|
|
|
162
191
|
/**
|
|
163
192
|
* Its input.
|
|
164
193
|
*/
|
|
165
|
-
input: HTMLInputElement;
|
|
194
|
+
input: HTMLInputElement | HTMLTextAreaElement;
|
|
166
195
|
/**
|
|
167
196
|
* The reason under it.
|
|
168
197
|
*/
|
|
169
198
|
error: HTMLElement;
|
|
199
|
+
/**
|
|
200
|
+
* The help under it.
|
|
201
|
+
*/
|
|
202
|
+
hint: HTMLElement;
|
|
203
|
+
/**
|
|
204
|
+
* Whether its value is still being worked out.
|
|
205
|
+
*/
|
|
206
|
+
waiting: boolean;
|
|
170
207
|
};
|
|
171
208
|
/**
|
|
172
209
|
* Ask for several pieces of text in the page's dialog, one field each.
|
|
@@ -199,4 +236,4 @@ export declare function askFields({ title, fields: requests, action, cancel, ret
|
|
|
199
236
|
* validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
|
|
200
237
|
* });
|
|
201
238
|
*/
|
|
202
|
-
export declare function askText({ label, value, placeholder, validate, maxLength, ...dialog }: TextRequest): Promise<string | null>;
|
|
239
|
+
export declare function askText({ label, value, placeholder, validate, maxLength, multiline, rows, hint, waiting, ...dialog }: TextRequest): Promise<string | null>;
|
package/src/ui/text-dialog.js
CHANGED
|
@@ -19,8 +19,16 @@ import { focusedElement } from '../dom/focus.js';
|
|
|
19
19
|
* @typedef {object} FieldRequest
|
|
20
20
|
* @property {string} name Its key in the answer ("folder"), and part of its id.
|
|
21
21
|
* @property {string} label Its label ("Folder").
|
|
22
|
-
* @property {string} [value] The text to start with. Default ''.
|
|
22
|
+
* @property {string | Promise<string>} [value] The text to start with. Default ''. A promise is text
|
|
23
|
+
* still being worked out (an on-device model's suggestion): the field waits, disabled, with
|
|
24
|
+
* `waiting` under it, and takes the text when it comes ('' if it fails).
|
|
25
|
+
* @property {string} [waiting] What the field says while its `value` is worked out ("Finding words…").
|
|
23
26
|
* @property {string} [placeholder] Shown while it's empty. Default none.
|
|
27
|
+
* @property {boolean} [multiline] A `<textarea>` (cr-textarea) rather than one line: Enter starts a new
|
|
28
|
+
* line, and only the action button saves. Default false.
|
|
29
|
+
* @property {number} [rows] A multi-line field's height in lines. Default 6.
|
|
30
|
+
* @property {string | ((text: string) => string)} [hint] Help under the field (cr-input's
|
|
31
|
+
* description), or a function of its text, kept up as it's typed ("12 words"). Default none.
|
|
24
32
|
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
25
33
|
* shown under the field; null when it can. Default: anything but blank.
|
|
26
34
|
* @property {number} [maxLength] Its `maxlength`. Default none.
|
|
@@ -74,14 +82,14 @@ export function checkText(text, validate = () => null) {
|
|
|
74
82
|
*
|
|
75
83
|
* @param {string} name - The field's name.
|
|
76
84
|
* @param {boolean} only - Whether it's the dialog's only field.
|
|
77
|
-
* @returns {{input: string, error: string}} The ids.
|
|
85
|
+
* @returns {{input: string, error: string, hint: string}} The ids.
|
|
78
86
|
* @example
|
|
79
|
-
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
|
|
87
|
+
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error', hint: 'text-dialog-folder-hint'}
|
|
80
88
|
*/
|
|
81
89
|
export function fieldIds(name, only) {
|
|
82
90
|
return only
|
|
83
|
-
? { input: 'text-dialog-input', error: 'text-dialog-error' }
|
|
84
|
-
: { input: `text-dialog-${name}`, error: `text-dialog-${name}-error` };
|
|
91
|
+
? { input: 'text-dialog-input', error: 'text-dialog-error', hint: 'text-dialog-hint' }
|
|
92
|
+
: { input: `text-dialog-${name}`, error: `text-dialog-${name}-error`, hint: `text-dialog-${name}-hint` };
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/**
|
|
@@ -101,8 +109,10 @@ export function fieldIds(name, only) {
|
|
|
101
109
|
*
|
|
102
110
|
* @typedef {object} Field
|
|
103
111
|
* @property {FieldRequest} request What was asked.
|
|
104
|
-
* @property {HTMLInputElement} input Its input.
|
|
112
|
+
* @property {HTMLInputElement | HTMLTextAreaElement} input Its input.
|
|
105
113
|
* @property {HTMLElement} error The reason under it.
|
|
114
|
+
* @property {HTMLElement} hint The help under it.
|
|
115
|
+
* @property {boolean} waiting Whether its value is still being worked out.
|
|
106
116
|
*/
|
|
107
117
|
|
|
108
118
|
/** Each page's dialog, made on first use. @type {WeakMap<Document, Parts>} */
|
|
@@ -161,14 +171,19 @@ function fieldFor(doc, request, only) {
|
|
|
161
171
|
const row = element(doc, 'div', { class: 'field' });
|
|
162
172
|
const label = element(doc, 'label', { for: ids.input });
|
|
163
173
|
label.textContent = request.label;
|
|
164
|
-
const
|
|
165
|
-
input
|
|
174
|
+
const described = `${ids.error} ${ids.hint}`;
|
|
175
|
+
const input = request.multiline
|
|
176
|
+
? element(doc, 'textarea', { id: ids.input, rows: String(request.rows ?? 6), 'aria-describedby': described })
|
|
177
|
+
: element(doc, 'input', { id: ids.input, type: 'text', autocomplete: 'off', 'aria-describedby': described });
|
|
178
|
+
input.value = typeof request.value === 'string' ? request.value : '';
|
|
166
179
|
if (request.placeholder) input.placeholder = request.placeholder;
|
|
167
180
|
if (request.maxLength !== undefined) input.maxLength = request.maxLength;
|
|
168
181
|
const error = element(doc, 'span', { id: ids.error, class: 'hint error' });
|
|
169
182
|
error.hidden = true;
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const hint = element(doc, 'span', { id: ids.hint, class: 'hint secondary' });
|
|
184
|
+
hint.hidden = true;
|
|
185
|
+
row.append(label, input, error, hint);
|
|
186
|
+
return { row, field: { request, input, error, hint, waiting: false } };
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
/** The open dialog's settle function, per page. @type {WeakMap<Document, (answer: Record<string, string> | null) => void>} */
|
|
@@ -205,12 +220,14 @@ export function askFields({
|
|
|
205
220
|
parts.confirm.textContent = action;
|
|
206
221
|
parts.cancel.textContent = cancel;
|
|
207
222
|
|
|
208
|
-
const allOk = () => fields.every(({ input, request }) => checkText(input.value, request.validate).ok);
|
|
223
|
+
const allOk = () => fields.every(({ input, request, waiting }) => !waiting && checkText(input.value, request.validate).ok);
|
|
209
224
|
/** @param {Field} field */
|
|
210
|
-
const refresh = ({ input, error, request }) => {
|
|
211
|
-
const { reason } = checkText(input.value, request.validate);
|
|
225
|
+
const refresh = ({ input, error, hint, request, waiting }) => {
|
|
226
|
+
const { reason } = waiting ? { reason: '' } : checkText(input.value, request.validate);
|
|
212
227
|
error.textContent = reason;
|
|
213
228
|
error.hidden = !reason;
|
|
229
|
+
hint.textContent = waiting ? request.waiting ?? '' : typeof request.hint === 'function' ? request.hint(input.value) : request.hint ?? '';
|
|
230
|
+
hint.hidden = !hint.textContent;
|
|
214
231
|
};
|
|
215
232
|
for (const field of fields) refresh(field);
|
|
216
233
|
parts.confirm.disabled = !allOk();
|
|
@@ -232,8 +249,29 @@ export function askFields({
|
|
|
232
249
|
});
|
|
233
250
|
};
|
|
234
251
|
pending.set(doc, settle);
|
|
252
|
+
let typed = false;
|
|
253
|
+
// A value still being worked out: the field waits, then takes it, unless the dialog has moved on.
|
|
254
|
+
for (const field of fields) {
|
|
255
|
+
const { value } = field.request;
|
|
256
|
+
if (typeof value !== 'object' || !value) continue;
|
|
257
|
+
field.waiting = true;
|
|
258
|
+
field.input.disabled = true;
|
|
259
|
+
refresh(field);
|
|
260
|
+
value.catch(() => '').then((text) => {
|
|
261
|
+
if (signal.aborted) return;
|
|
262
|
+
field.waiting = false;
|
|
263
|
+
field.input.disabled = false;
|
|
264
|
+
field.input.value = text;
|
|
265
|
+
refresh(field);
|
|
266
|
+
parts.confirm.disabled = !allOk();
|
|
267
|
+
// The person was waiting for it: the focus moves in, unless they've been typing meanwhile.
|
|
268
|
+
if (!typed) field.input.focus();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
parts.confirm.disabled = !allOk();
|
|
235
272
|
for (const field of fields) {
|
|
236
273
|
field.input.addEventListener('input', () => {
|
|
274
|
+
typed = true;
|
|
237
275
|
refresh(field);
|
|
238
276
|
parts.confirm.disabled = !allOk();
|
|
239
277
|
}, { signal });
|
|
@@ -256,7 +294,7 @@ export function askFields({
|
|
|
256
294
|
|
|
257
295
|
parts.dialog.showModal();
|
|
258
296
|
// The first field still to fill in, or the first, its text selected to type over.
|
|
259
|
-
(fields.find(({ input }) => !input.value) ?? fields[0])?.input.select();
|
|
297
|
+
(fields.find(({ input }) => !input.value && !input.disabled) ?? fields[0])?.input.select();
|
|
260
298
|
});
|
|
261
299
|
}
|
|
262
300
|
|
|
@@ -275,8 +313,8 @@ export function askFields({
|
|
|
275
313
|
* });
|
|
276
314
|
*/
|
|
277
315
|
export async function askText({
|
|
278
|
-
label, value, placeholder, validate, maxLength, ...dialog
|
|
316
|
+
label, value, placeholder, validate, maxLength, multiline, rows, hint, waiting, ...dialog
|
|
279
317
|
}) {
|
|
280
|
-
const answer = await askFields({ ...dialog, fields: [{ name: 'input', label, value, placeholder, validate, maxLength }] });
|
|
318
|
+
const answer = await askFields({ ...dialog, fields: [{ name: 'input', label, value, placeholder, validate, maxLength, multiline, rows, hint, waiting }] });
|
|
281
319
|
return answer?.input ?? null;
|
|
282
320
|
}
|