cursedbelt-core 2.13.0 → 2.13.2
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/dist/core/palette/hotkeys.d.ts +4 -1
- package/dist/core/palette/hotkeys.js +9 -5
- package/dist/ctgrClient/convertRequest.js +21 -5
- package/package.json +1 -1
- package/src/core/palette/hotkeys.ts +9 -5
- package/src/core/palette/palette.spec.ts +8 -0
- package/src/ctgrClient/convertRequest.ts +21 -4
- package/src/ctgrClient/ctgrClient.spec.ts +32 -0
|
@@ -15,7 +15,10 @@ export interface ParsedHotkey extends HotkeyMods {
|
|
|
15
15
|
* returns `false` when there's no `navigator`.
|
|
16
16
|
*/
|
|
17
17
|
export declare function isApplePlatform(): boolean;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
|
|
20
|
+
* key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
|
|
21
|
+
*/
|
|
19
22
|
export declare function parseHotkey(binding: string, isApple?: boolean): ParsedHotkey;
|
|
20
23
|
/** Does a keyboard event satisfy `binding` — exact modifier set + the key? */
|
|
21
24
|
export declare function matchHotkey(e: KeyboardEvent, binding: string, isApple?: boolean): boolean;
|
|
@@ -21,14 +21,18 @@ export function isApplePlatform() {
|
|
|
21
21
|
const probe = `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`;
|
|
22
22
|
return /mac|iphone|ipad|ipod/i.test(probe);
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
|
|
26
|
+
* key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
|
|
27
|
+
*/
|
|
25
28
|
export function parseHotkey(binding, isApple = isApplePlatform()) {
|
|
26
|
-
const
|
|
29
|
+
const split = binding
|
|
27
30
|
.toLowerCase()
|
|
28
31
|
.split('+')
|
|
29
|
-
.map((p) => p.trim())
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
+
.map((p) => p.trim());
|
|
33
|
+
const plusKey = split.length > 1 && split[split.length - 1] === '';
|
|
34
|
+
const parts = split.filter(Boolean);
|
|
35
|
+
const key = plusKey ? '+' : (parts.pop() ?? '');
|
|
32
36
|
const tokens = new Set(parts);
|
|
33
37
|
const mod = tokens.has('$mod');
|
|
34
38
|
return {
|
|
@@ -33,7 +33,7 @@ export async function convertIfOnlyGETRequests(url, init = { method: 'GET' }, fo
|
|
|
33
33
|
if (!METHOD_TO_ABBREV[upper]) {
|
|
34
34
|
throw new Error(`ctgr: "${method}" is not a tunnelable method`);
|
|
35
35
|
}
|
|
36
|
-
const body = form ? await formDataToBody(form) : bodyFromInit(init.body);
|
|
36
|
+
const body = form ? await formDataToBody(form) : bodyFromInit(init.body, upper, url);
|
|
37
37
|
const getInit = toGetInit(init);
|
|
38
38
|
const { chunks } = await encodeRequest(upper, url, body);
|
|
39
39
|
return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
|
|
@@ -62,10 +62,26 @@ async function formDataToBody(formData) {
|
|
|
62
62
|
}
|
|
63
63
|
return { __files: files, ...fields };
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The request body as a JS value: a JSON string is parsed, a plain string rides as itself.
|
|
67
|
+
*
|
|
68
|
+
* 🔴 Anything else is REFUSED. The tunnel carries a JSON value or a form's files; a `File`, a
|
|
69
|
+
* `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-serialised as `{}` —
|
|
70
|
+
* desk's import preview posted a 3.4 MB workbook and the server described "a JSON file" with `{}`
|
|
71
|
+
* as its sample (forge task 3443). A loud error at the call is the bug; a silent `{}` is data loss.
|
|
72
|
+
* Send an upload as `FormData`, which is carried whole.
|
|
73
|
+
*/
|
|
74
|
+
function bodyFromInit(body, method, url) {
|
|
75
|
+
if (body === null || body === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof body !== 'string') {
|
|
78
|
+
// A plain object or array a caller cast to `BodyInit` serialises as itself — carried as before.
|
|
79
|
+
const proto = Object.getPrototypeOf(body);
|
|
80
|
+
if (Array.isArray(body) || proto === Object.prototype || proto === null)
|
|
81
|
+
return body;
|
|
82
|
+
const shape = Object.prototype.toString.call(body).slice(8, -1);
|
|
83
|
+
throw new Error(`ctgr: a ${shape} body cannot ride the GET-only tunnel (${method} ${url}) — send it as FormData or a JSON string`);
|
|
84
|
+
}
|
|
69
85
|
try {
|
|
70
86
|
return JSON.parse(body);
|
|
71
87
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.13.
|
|
3
|
+
"version": "2.13.2",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -35,14 +35,18 @@ export function isApplePlatform(): boolean {
|
|
|
35
35
|
return /mac|iphone|ipad|ipod/i.test(probe);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Split a `'+'`-separated binding into its required modifiers + final key. `+` is itself a
|
|
40
|
+
* key — a bare `'+'` or `'$mod++'` splits into empty parts, and the key is the `+`.
|
|
41
|
+
*/
|
|
39
42
|
export function parseHotkey(binding: string, isApple = isApplePlatform()): ParsedHotkey {
|
|
40
|
-
const
|
|
43
|
+
const split = binding
|
|
41
44
|
.toLowerCase()
|
|
42
45
|
.split('+')
|
|
43
|
-
.map((p) => p.trim())
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
+
.map((p) => p.trim());
|
|
47
|
+
const plusKey = split.length > 1 && split[split.length - 1] === '';
|
|
48
|
+
const parts = split.filter(Boolean);
|
|
49
|
+
const key = plusKey ? '+' : (parts.pop() ?? '');
|
|
46
50
|
const tokens = new Set(parts);
|
|
47
51
|
const mod = tokens.has('$mod');
|
|
48
52
|
return {
|
|
@@ -133,6 +133,14 @@ describe('formatHotkey', () => {
|
|
|
133
133
|
test('multiple modifiers order Apple-style (⌃⌥⇧⌘)', () => {
|
|
134
134
|
expect(formatHotkey('$mod+shift+p', true)).toBe('⇧⌘P');
|
|
135
135
|
});
|
|
136
|
+
test('`+` is a key too — a bare `+` or `shift++` draws a + cap, never an empty one', () => {
|
|
137
|
+
expect(formatHotkey('+', true)).toBe('+');
|
|
138
|
+
expect(formatHotkey('+', false)).toBe('+');
|
|
139
|
+
expect(formatHotkey('$mod++', true)).toBe('⌘+');
|
|
140
|
+
expect(formatHotkey('$mod++', false)).toBe('Ctrl +');
|
|
141
|
+
expect(parseHotkey('shift++')).toMatchObject({ shift: true, key: '+' });
|
|
142
|
+
expect(matchHotkey(kd({ key: '+' }), '+', true)).toBe(true);
|
|
143
|
+
});
|
|
136
144
|
});
|
|
137
145
|
|
|
138
146
|
// ── ShortcutRegistry ──────────────────────────────────────────────────────────
|
|
@@ -57,7 +57,7 @@ export async function convertIfOnlyGETRequests(
|
|
|
57
57
|
throw new Error(`ctgr: "${method}" is not a tunnelable method`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body);
|
|
60
|
+
const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body, upper, url);
|
|
61
61
|
const getInit = toGetInit(init);
|
|
62
62
|
const { chunks } = await encodeRequest(upper, url, body);
|
|
63
63
|
return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
|
|
@@ -89,9 +89,26 @@ async function formDataToBody(formData: FormData): Promise<Record<string, unknow
|
|
|
89
89
|
return { __files: files, ...fields };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The request body as a JS value: a JSON string is parsed, a plain string rides as itself.
|
|
94
|
+
*
|
|
95
|
+
* 🔴 Anything else is REFUSED. The tunnel carries a JSON value or a form's files; a `File`, a
|
|
96
|
+
* `Blob`, bytes, a stream or `URLSearchParams` handed through here was JSON-serialised as `{}` —
|
|
97
|
+
* desk's import preview posted a 3.4 MB workbook and the server described "a JSON file" with `{}`
|
|
98
|
+
* as its sample (forge task 3443). A loud error at the call is the bug; a silent `{}` is data loss.
|
|
99
|
+
* Send an upload as `FormData`, which is carried whole.
|
|
100
|
+
*/
|
|
101
|
+
function bodyFromInit(body: BodyInit | null | undefined, method: string, url: string): unknown {
|
|
102
|
+
if (body === null || body === undefined) return undefined;
|
|
103
|
+
if (typeof body !== 'string') {
|
|
104
|
+
// A plain object or array a caller cast to `BodyInit` serialises as itself — carried as before.
|
|
105
|
+
const proto = Object.getPrototypeOf(body);
|
|
106
|
+
if (Array.isArray(body) || proto === Object.prototype || proto === null) return body;
|
|
107
|
+
const shape = Object.prototype.toString.call(body).slice(8, -1);
|
|
108
|
+
throw new Error(
|
|
109
|
+
`ctgr: a ${shape} body cannot ride the GET-only tunnel (${method} ${url}) — send it as FormData or a JSON string`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
95
112
|
try {
|
|
96
113
|
return JSON.parse(body);
|
|
97
114
|
} catch {
|
|
@@ -132,4 +132,36 @@ describe('convertIfOnlyGETRequests', () => {
|
|
|
132
132
|
expect(decoded.files?.[0]).toMatchObject({ originalname: 'p.bin', mimetype: 'image/png' });
|
|
133
133
|
expect(decoded.body).toEqual({ name: 'pic' });
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
// forge task 3443: desk posted a workbook as a raw `File`, and it arrived as `{}`.
|
|
137
|
+
test('mode ON: a body the tunnel cannot carry is refused, never sent as {}', async () => {
|
|
138
|
+
setCtgrMode(true);
|
|
139
|
+
const bodies: BodyInit[] = [
|
|
140
|
+
new File([new Uint8Array([0x50, 0x4b, 3, 4])], 'budget.xlsx'),
|
|
141
|
+
new Blob(['x']),
|
|
142
|
+
new Uint8Array([1, 2, 3]),
|
|
143
|
+
new Uint8Array([1, 2, 3]).buffer,
|
|
144
|
+
new URLSearchParams({ a: '1' }),
|
|
145
|
+
];
|
|
146
|
+
for (const body of bodies) {
|
|
147
|
+
await expect(convertIfOnlyGETRequests('/api/v1/files/preview', { method: 'POST', body })).rejects.toThrow(
|
|
148
|
+
/cannot ride the GET-only tunnel \(POST \/api\/v1\/files\/preview\) — send it as FormData/,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('mode ON: a plain object a caller cast to BodyInit still rides as JSON', async () => {
|
|
154
|
+
setCtgrMode(true);
|
|
155
|
+
const body = { name: 'Budget' } as unknown as BodyInit;
|
|
156
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files', { method: 'POST', body });
|
|
157
|
+
const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
|
|
158
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
159
|
+
expect(decoded.body).toEqual({ name: 'Budget' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('mode OFF: any body passes through untouched — only the tunnel is particular', async () => {
|
|
163
|
+
const body = new Blob(['x']);
|
|
164
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files/preview', { method: 'POST', body });
|
|
165
|
+
expect(requests[0].init.body).toBe(body);
|
|
166
|
+
});
|
|
135
167
|
});
|