@arraypress/extension-kit 1.4.0 → 1.6.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 +54 -6
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/index.d.ts +9 -1
- package/src/index.js +6 -1
- package/src/scripting/registration.d.ts +36 -0
- package/src/scripting/registration.js +54 -0
- package/src/testing/downloads.d.ts +41 -0
- package/src/testing/downloads.js +70 -0
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +6 -2
- package/src/testing/live.d.ts +146 -0
- package/src/testing/live.js +151 -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/text-dialog.d.ts +107 -37
- package/src/ui/text-dialog.js +167 -72
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a live checker (a run over real sites, never in CI) shares: its
|
|
3
|
+
* command line (which sites, how many at once, a proxy, how the browser
|
|
4
|
+
* looks) and where its traffic leaves for the internet, which decides what
|
|
5
|
+
* many sites show (cookie banners, prices, currency).
|
|
6
|
+
*
|
|
7
|
+
* @module testing/live
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @import { APIRequestContext } from 'playwright' */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A proxy, as Playwright takes it.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {object} Proxy
|
|
16
|
+
* @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
|
|
17
|
+
* @property {string} [username] From the address, if it has one.
|
|
18
|
+
* @property {string} [password] From the address, if it has one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A live checker's options.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} LiveOptions
|
|
25
|
+
* @property {string[]} only Only sites whose address or name contains one of these (any case).
|
|
26
|
+
* @property {string[]} categories Only these categories (by id).
|
|
27
|
+
* @property {number} concurrency Sites checked at once.
|
|
28
|
+
* @property {Proxy | null} proxy The proxy every request goes through.
|
|
29
|
+
* @property {string} proxyLabel The proxy's address without its password, for the output.
|
|
30
|
+
* @property {string} locale The browser's language (navigator.language, Accept-Language).
|
|
31
|
+
* @property {string} timezone The browser's time zone.
|
|
32
|
+
* @property {string} out Where the report goes ('' for the checker's default).
|
|
33
|
+
* @property {boolean} help Show the usage and stop.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read a proxy address.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} address - e.g. "http://user:pass@host:3128".
|
|
40
|
+
* @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
|
|
41
|
+
* @throws {Error} When it isn't an address.
|
|
42
|
+
* @example
|
|
43
|
+
* parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
|
|
44
|
+
*/
|
|
45
|
+
export function parseProxy(address) {
|
|
46
|
+
const url = new URL(address.includes('://') ? address : `http://${address}`);
|
|
47
|
+
if (!url.hostname) throw new Error(`Not a proxy address: ${address}`);
|
|
48
|
+
const server = `${url.protocol}//${url.host}`;
|
|
49
|
+
const username = decodeURIComponent(url.username);
|
|
50
|
+
const password = decodeURIComponent(url.password);
|
|
51
|
+
return {
|
|
52
|
+
proxy: { server, ...(username && { username }), ...(password && { password }) },
|
|
53
|
+
label: `${url.protocol}//${username ? `${username}${password ? ':•••' : ''}@` : ''}${url.host}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a live checker's command line: `--only <word>` (repeatable, or bare
|
|
59
|
+
* words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
|
|
60
|
+
* (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
|
|
61
|
+
* `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
|
|
62
|
+
*
|
|
63
|
+
* @param {readonly string[]} argv - The arguments after the script.
|
|
64
|
+
* @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
|
|
65
|
+
* @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
|
|
66
|
+
* usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
|
|
67
|
+
* @returns {LiveOptions} The options.
|
|
68
|
+
* @throws {Error} On an unknown option or a bad value.
|
|
69
|
+
* @example
|
|
70
|
+
* parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
|
|
71
|
+
*/
|
|
72
|
+
export function parseLiveOptions(argv, env, { usage, concurrency = 3, maxConcurrency = 8, locale = 'en-GB', timezone = 'Europe/London' }) {
|
|
73
|
+
/** @type {LiveOptions} */
|
|
74
|
+
const options = { only: [], categories: [], concurrency, proxy: null, proxyLabel: '', locale, timezone, out: '', help: false };
|
|
75
|
+
let proxy = env.HTTPS_PROXY || env.https_proxy || '';
|
|
76
|
+
for (let at = 0; at < argv.length; at += 1) {
|
|
77
|
+
const arg = argv[at];
|
|
78
|
+
const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
79
|
+
const flag = equals > 0 ? arg.slice(0, equals) : arg;
|
|
80
|
+
const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
|
|
81
|
+
const value = () => {
|
|
82
|
+
const next = inline ?? argv[++at];
|
|
83
|
+
if (next === undefined || next === '') throw new Error(`${flag} needs a value`);
|
|
84
|
+
return next;
|
|
85
|
+
};
|
|
86
|
+
switch (flag) {
|
|
87
|
+
case '--only': options.only.push(...value().split(',')); break;
|
|
88
|
+
case '--category': options.categories.push(...value().split(',')); break;
|
|
89
|
+
case '--concurrency': {
|
|
90
|
+
const count = Number(value());
|
|
91
|
+
if (!Number.isInteger(count) || count < 1 || count > maxConcurrency) throw new Error(`--concurrency must be 1 to ${maxConcurrency}`);
|
|
92
|
+
options.concurrency = count;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case '--proxy': proxy = value(); break;
|
|
96
|
+
case '--locale': options.locale = value(); break;
|
|
97
|
+
case '--timezone': options.timezone = value(); break;
|
|
98
|
+
case '--out': options.out = value(); break;
|
|
99
|
+
case '--help': case '-h': options.help = true; break;
|
|
100
|
+
default:
|
|
101
|
+
if (flag.startsWith('-')) throw new Error(`Unknown option ${flag}\n\n${usage}`);
|
|
102
|
+
options.only.push(flag);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (proxy) ({ proxy: options.proxy, label: options.proxyLabel } = parseProxy(proxy));
|
|
106
|
+
return options;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The exit point.
|
|
111
|
+
*
|
|
112
|
+
* @typedef {object} Exit
|
|
113
|
+
* @property {string} ip The public address.
|
|
114
|
+
* @property {string} country Its ISO country code ("GB", "TH").
|
|
115
|
+
* @property {string} where Readable: city and organisation, where the service says.
|
|
116
|
+
* @property {string} source The service that answered.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lookup services that need no key, tried in order, each with how to read its answer.
|
|
121
|
+
*
|
|
122
|
+
* @type {readonly { url: string, read: (data: Record<string, unknown>) => { ip: unknown, country: unknown, where: unknown[] } }[]}
|
|
123
|
+
*/
|
|
124
|
+
const SERVICES = [
|
|
125
|
+
{ url: 'https://ipinfo.io/json', read: (data) => ({ ip: data.ip, country: data.country, where: [data.city, data.org] }) },
|
|
126
|
+
{ url: 'https://api.country.is/', read: (data) => ({ ip: data.ip, country: data.country, where: [] }) },
|
|
127
|
+
{ url: 'https://ipapi.co/json/', read: (data) => ({ ip: data.ip, country: data.country_code, where: [data.city, data.org] }) },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where the browser's traffic leaves for the internet: its public IP's
|
|
132
|
+
* country, asked once through the same proxy the browser uses. Sites go by
|
|
133
|
+
* that address, not the browser's language or time zone.
|
|
134
|
+
*
|
|
135
|
+
* @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
|
|
136
|
+
* @returns {Promise<Exit | null>} null when no service answered.
|
|
137
|
+
*/
|
|
138
|
+
export async function exitPoint(request) {
|
|
139
|
+
for (const { url, read } of SERVICES) {
|
|
140
|
+
try {
|
|
141
|
+
const response = await request.get(url, { timeout: 10000, headers: { accept: 'application/json' } });
|
|
142
|
+
if (!response.ok()) continue;
|
|
143
|
+
const { ip, country, where } = read(await response.json());
|
|
144
|
+
if (typeof country !== 'string' || !country) continue;
|
|
145
|
+
return { ip: String(ip ?? ''), country: country.toUpperCase(), where: where.filter((part) => typeof part === 'string' && part).join(', '), source: new URL(url).host };
|
|
146
|
+
} catch {
|
|
147
|
+
// The next service.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
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
|
/**
|
package/src/ui/text-dialog.d.ts
CHANGED
|
@@ -1,43 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chrome's
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Chrome's text dialog (cr-dialog with cr-inputs): a title, labelled fields,
|
|
3
|
+
* Cancel and an action button. One field, as chrome://bookmarks asks for a
|
|
4
|
+
* folder's name (`askText`); several, as chrome://settings/searchEngines asks
|
|
5
|
+
* for a site search (`askFields`). The action button stays disabled while a
|
|
6
|
+
* field is blank or isn't valid, with the reason under that field. Enter
|
|
7
|
+
* means the action button, Esc and Cancel cancel, and the focus goes back to
|
|
8
|
+
* what opened it. The page needs no markup: the dialog (`#text-dialog`) is
|
|
9
|
+
* made the first time, its fields each time it's asked.
|
|
8
10
|
*
|
|
9
11
|
* @module ui/text-dialog
|
|
10
12
|
*/
|
|
11
|
-
export type
|
|
13
|
+
export type FieldRequest = {
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
15
|
+
* Its key in the answer ("folder"), and part of its id.
|
|
14
16
|
*/
|
|
15
|
-
|
|
17
|
+
name: string;
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
19
|
+
* Its label ("Folder").
|
|
18
20
|
*/
|
|
19
21
|
label: string;
|
|
20
22
|
/**
|
|
21
|
-
* The text to start with
|
|
23
|
+
* The text to start with. Default ''.
|
|
22
24
|
*/
|
|
23
25
|
value?: string;
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
action?: string;
|
|
28
|
-
/**
|
|
29
|
-
* The cancel button's text. Default "Cancel".
|
|
27
|
+
* Shown while it's empty. Default none.
|
|
30
28
|
*/
|
|
31
|
-
|
|
29
|
+
placeholder?: string;
|
|
32
30
|
/**
|
|
33
31
|
* Why the (non-blank) text can't be used,
|
|
34
32
|
* shown under the field; null when it can. Default: anything but blank.
|
|
35
33
|
*/
|
|
36
34
|
validate?: (text: string) => string | null;
|
|
37
35
|
/**
|
|
38
|
-
*
|
|
36
|
+
* Its `maxlength`. Default none.
|
|
39
37
|
*/
|
|
40
38
|
maxLength?: number;
|
|
39
|
+
};
|
|
40
|
+
export type DialogRequest = {
|
|
41
|
+
/**
|
|
42
|
+
* The dialog's title ("Rename folder").
|
|
43
|
+
*/
|
|
44
|
+
title: string;
|
|
45
|
+
/**
|
|
46
|
+
* The action button's text. Default "Save".
|
|
47
|
+
*/
|
|
48
|
+
action?: string;
|
|
49
|
+
/**
|
|
50
|
+
* The cancel button's text. Default "Cancel".
|
|
51
|
+
*/
|
|
52
|
+
cancel?: string;
|
|
41
53
|
/**
|
|
42
54
|
* Where the focus
|
|
43
55
|
* goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
|
|
@@ -49,23 +61,44 @@ export type TextRequest = {
|
|
|
49
61
|
*/
|
|
50
62
|
doc?: Document;
|
|
51
63
|
};
|
|
64
|
+
export type TextRequest = DialogRequest & Omit<FieldRequest, 'name'>;
|
|
65
|
+
export type FieldsRequest = DialogRequest & {
|
|
66
|
+
fields: FieldRequest[];
|
|
67
|
+
};
|
|
52
68
|
/**
|
|
53
|
-
*
|
|
69
|
+
* One field of the dialog.
|
|
54
70
|
*
|
|
55
|
-
* @typedef {object}
|
|
71
|
+
* @typedef {object} FieldRequest
|
|
72
|
+
* @property {string} name Its key in the answer ("folder"), and part of its id.
|
|
73
|
+
* @property {string} label Its label ("Folder").
|
|
74
|
+
* @property {string} [value] The text to start with. Default ''.
|
|
75
|
+
* @property {string} [placeholder] Shown while it's empty. Default none.
|
|
76
|
+
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
77
|
+
* shown under the field; null when it can. Default: anything but blank.
|
|
78
|
+
* @property {number} [maxLength] Its `maxlength`. Default none.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* What the dialog asks, whatever its fields.
|
|
82
|
+
*
|
|
83
|
+
* @typedef {object} DialogRequest
|
|
56
84
|
* @property {string} title The dialog's title ("Rename folder").
|
|
57
|
-
* @property {string} label The field's label ("Name").
|
|
58
|
-
* @property {string} [value] The text to start with (selected). Default ''.
|
|
59
85
|
* @property {string} [action] The action button's text. Default "Save".
|
|
60
86
|
* @property {string} [cancel] The cancel button's text. Default "Cancel".
|
|
61
|
-
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
62
|
-
* shown under the field; null when it can. Default: anything but blank.
|
|
63
|
-
* @property {number} [maxLength] The field's `maxlength`. Default none.
|
|
64
87
|
* @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
|
|
65
88
|
* goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
|
|
66
89
|
* Default what had the focus when it opened.
|
|
67
90
|
* @property {Document} [doc] The page. Default `document`.
|
|
68
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* What the one-field dialog asks.
|
|
94
|
+
*
|
|
95
|
+
* @typedef {DialogRequest & Omit<FieldRequest, 'name'>} TextRequest
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* What the dialog with several fields asks.
|
|
99
|
+
*
|
|
100
|
+
* @typedef {DialogRequest & {fields: FieldRequest[]}} FieldsRequest
|
|
101
|
+
*/
|
|
69
102
|
/**
|
|
70
103
|
* Whether a text can be used, and the reason to show when it can't. Pure.
|
|
71
104
|
*
|
|
@@ -81,6 +114,20 @@ export declare function checkText(text: string, validate?: (text: string) => str
|
|
|
81
114
|
ok: boolean;
|
|
82
115
|
reason: string;
|
|
83
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* A field's element ids. A one-field dialog's are `text-dialog-input` and `text-dialog-error`;
|
|
119
|
+
* with several, each is named: `text-dialog-<name>` and `text-dialog-<name>-error`. Pure.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} name - The field's name.
|
|
122
|
+
* @param {boolean} only - Whether it's the dialog's only field.
|
|
123
|
+
* @returns {{input: string, error: string}} The ids.
|
|
124
|
+
* @example
|
|
125
|
+
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
|
|
126
|
+
*/
|
|
127
|
+
export declare function fieldIds(name: string, only: boolean): {
|
|
128
|
+
input: string;
|
|
129
|
+
error: string;
|
|
130
|
+
};
|
|
84
131
|
export type Parts = {
|
|
85
132
|
/**
|
|
86
133
|
* The `<dialog>`.
|
|
@@ -95,17 +142,9 @@ export type Parts = {
|
|
|
95
142
|
*/
|
|
96
143
|
title: HTMLElement;
|
|
97
144
|
/**
|
|
98
|
-
*
|
|
99
|
-
*/
|
|
100
|
-
label: HTMLLabelElement;
|
|
101
|
-
/**
|
|
102
|
-
* The field.
|
|
103
|
-
*/
|
|
104
|
-
input: HTMLInputElement;
|
|
105
|
-
/**
|
|
106
|
-
* The reason under the field.
|
|
145
|
+
* Where the fields go.
|
|
107
146
|
*/
|
|
108
|
-
|
|
147
|
+
body: HTMLElement;
|
|
109
148
|
/**
|
|
110
149
|
* The cancel button.
|
|
111
150
|
*/
|
|
@@ -115,8 +154,39 @@ export type Parts = {
|
|
|
115
154
|
*/
|
|
116
155
|
confirm: HTMLButtonElement;
|
|
117
156
|
};
|
|
157
|
+
export type Field = {
|
|
158
|
+
/**
|
|
159
|
+
* What was asked.
|
|
160
|
+
*/
|
|
161
|
+
request: FieldRequest;
|
|
162
|
+
/**
|
|
163
|
+
* Its input.
|
|
164
|
+
*/
|
|
165
|
+
input: HTMLInputElement;
|
|
166
|
+
/**
|
|
167
|
+
* The reason under it.
|
|
168
|
+
*/
|
|
169
|
+
error: HTMLElement;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Ask for several pieces of text in the page's dialog, one field each.
|
|
173
|
+
*
|
|
174
|
+
* @param {FieldsRequest} request - What to ask.
|
|
175
|
+
* @returns {Promise<Record<string, string> | null>} Each field's text by its name, trimmed; null
|
|
176
|
+
* when cancelled.
|
|
177
|
+
* @example
|
|
178
|
+
* const rule = await askFields({
|
|
179
|
+
* title: 'Add folder rule', action: 'Add',
|
|
180
|
+
* fields: [
|
|
181
|
+
* {name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom},
|
|
182
|
+
* {name: 'folder', label: 'Folder', placeholder: 'Receipts'},
|
|
183
|
+
* ],
|
|
184
|
+
* });
|
|
185
|
+
* if (rule) addRule(rule.from, rule.folder);
|
|
186
|
+
*/
|
|
187
|
+
export declare function askFields({ title, fields: requests, action, cancel, returnTo, doc, }: FieldsRequest): Promise<Record<string, string> | null>;
|
|
118
188
|
/**
|
|
119
|
-
* Ask for a piece of text in the page's one
|
|
189
|
+
* Ask for a piece of text in the page's dialog, with one field.
|
|
120
190
|
*
|
|
121
191
|
* @param {TextRequest} request - What to ask.
|
|
122
192
|
* @returns {Promise<string | null>} The text, trimmed; null when cancelled.
|
|
@@ -129,4 +199,4 @@ export type Parts = {
|
|
|
129
199
|
* validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
|
|
130
200
|
* });
|
|
131
201
|
*/
|
|
132
|
-
export declare function askText({
|
|
202
|
+
export declare function askText({ label, value, placeholder, validate, maxLength, ...dialog }: TextRequest): Promise<string | null>;
|