superdoc-macros 0.3.0 → 0.4.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 +13 -1
- package/dist/host/superdoc-host.d.ts +8 -0
- package/dist/host/superdoc-host.js +116 -27
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/manager.d.ts +25 -0
- package/dist/manager.js +66 -1
- package/dist/messages.d.ts +6 -1
- package/dist/messages.js +12 -2
- package/dist/recorder/recorder.d.ts +14 -0
- package/dist/recorder/recorder.js +1 -17
- package/dist/scripting/eval-runner.d.ts +13 -0
- package/dist/scripting/eval-runner.js +31 -2
- package/dist/scripting/iframe-runner.js +15 -4
- package/dist/shortcuts.d.ts +12 -0
- package/dist/shortcuts.js +19 -0
- package/dist/storage.d.ts +24 -1
- package/dist/storage.js +90 -2
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,7 +76,9 @@ if (!result.ok) console.warn(result.message);
|
|
|
76
76
|
|
|
77
77
|
### Security
|
|
78
78
|
|
|
79
|
-
The default is a **real sandbox**: scripts run in an iframe with `sandbox="allow-scripts"` only — an opaque origin, no access to the application's DOM, localStorage
|
|
79
|
+
The default is a **real sandbox**: scripts run in an iframe with `sandbox="allow-scripts"` only — an opaque origin, no access to the application's DOM, localStorage or cookies — plus a `default-src 'none'` CSP inside the iframe, so the script cannot fetch or open sockets to the public internet either. The script's only way to touch the document is the API above, with a time cap (default 30 s — enforced even against infinite loops, by removing the iframe) and a call cap (10,000). When a run ends (result, error or timeout), its bridge is revoked: any late call is rejected and can no longer touch the document.
|
|
80
|
+
|
|
81
|
+
Honest limits: the browser offers no per-iframe memory cap, and a host call that already reached the engine cannot be aborted mid-flight (the engine exposes no cancellation) — what is guaranteed is that nothing new starts.
|
|
80
82
|
|
|
81
83
|
If you must waive isolation (e.g. a CSP that blocks `srcdoc`), switch to the direct runner: `new MacroKit({ host, runner: 'eval' })` — see the warnings in the code.
|
|
82
84
|
|
|
@@ -134,6 +136,16 @@ kit.importState(json, { merge: true });
|
|
|
134
136
|
|
|
135
137
|
`MacroStorage` is a two-method interface — implement it to persist to a file (e.g. a plugin workspace).
|
|
136
138
|
|
|
139
|
+
Imports are strictly validated: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), and one invalid item rejects the whole file — no partial imports.
|
|
140
|
+
|
|
141
|
+
## Shortcut safety
|
|
142
|
+
|
|
143
|
+
Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
|
|
147
|
+
```
|
|
148
|
+
|
|
137
149
|
## Connecting a different host
|
|
138
150
|
|
|
139
151
|
The whole toolkit works against a single `MacroHost` interface (commands, text insertion, selection, replace, typing events). `createSuperdocHost` is the implementation for SuperDoc v2 in `ui: false` mode; another editor plugs in with its own implementation — see `src/types.ts` and the double in `tests/fake-host.ts`.
|
|
@@ -102,6 +102,14 @@ export interface SuperdocHostOptions {
|
|
|
102
102
|
superdoc: SuperdocLike;
|
|
103
103
|
/** The element the document renders in — typing events are captured on it. */
|
|
104
104
|
container?: HTMLElement | null;
|
|
105
|
+
/**
|
|
106
|
+
* Whether the adapter may fall back to the engine's internal ProseMirror
|
|
107
|
+
* view for the operations that have no public surface: backward/forward
|
|
108
|
+
* deletion, full-document text, and insertion when the Document API is
|
|
109
|
+
* missing. Default: true. Hosts that want to stay strictly on public
|
|
110
|
+
* surfaces set false — those operations then fail closed.
|
|
111
|
+
*/
|
|
112
|
+
viewFallback?: boolean;
|
|
105
113
|
}
|
|
106
114
|
export interface SuperdocMacroHost extends MacroHost {
|
|
107
115
|
/** Removes the observation wrapper and the DOM listeners. Call before swapping documents. */
|
|
@@ -43,40 +43,101 @@ function emptySelection() {
|
|
|
43
43
|
/* ---------- The implementation ---------- */
|
|
44
44
|
export function createSuperdocHost(options) {
|
|
45
45
|
const { superdoc, container } = options;
|
|
46
|
+
const viewFallback = options.viewFallback ?? true;
|
|
46
47
|
// Read at call time, never cached: activeEditor is replaced on every document open.
|
|
47
48
|
const commands = () => superdoc.ui?.commands ?? null;
|
|
48
49
|
const doc = () => superdoc.activeEditor?.doc ?? null;
|
|
49
|
-
const view = () => superdoc.activeEditor?.view ?? null;
|
|
50
50
|
const search = () => superdoc.ui?.search ?? null;
|
|
51
|
+
/**
|
|
52
|
+
* The single gate to the internal ProseMirror view. Every use of a
|
|
53
|
+
* non-public surface goes through here, so disabling the fallback (or a
|
|
54
|
+
* future engine that hides the view) degrades to closed failures in one
|
|
55
|
+
* place.
|
|
56
|
+
*/
|
|
57
|
+
const view = () => viewFallback ? (superdoc.activeEditor?.view ?? null) : null;
|
|
51
58
|
const commandListeners = new Set();
|
|
52
59
|
const inputListeners = new Set();
|
|
53
|
-
|
|
60
|
+
const emitInput = (mapped) => {
|
|
61
|
+
for (const listener of inputListeners) {
|
|
62
|
+
try {
|
|
63
|
+
listener(mapped);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.warn('[superdoc-macros] input listener threw', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/* Command observation: wrap executeAsync, once, restored on dispose.
|
|
71
|
+
Listeners are notified only after the engine reports success — a command
|
|
72
|
+
that was refused (not routed, or a failed receipt) must not enter a
|
|
73
|
+
recording, or replay would re-fail it or, worse, apply it in a context
|
|
74
|
+
where it now succeeds unintended. */
|
|
54
75
|
const wrapped = commands();
|
|
55
76
|
const originalExecuteAsync = wrapped?.executeAsync;
|
|
56
77
|
if (wrapped && originalExecuteAsync) {
|
|
57
78
|
wrapped.executeAsync = function (id, payload) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
const result = originalExecuteAsync.call(wrapped, id, payload);
|
|
80
|
+
void Promise.resolve(result)
|
|
81
|
+
.then((value) => {
|
|
82
|
+
if (value === false)
|
|
83
|
+
return;
|
|
84
|
+
if (typeof value === 'object' && value !== null && value.success === false)
|
|
85
|
+
return;
|
|
86
|
+
for (const listener of commandListeners) {
|
|
87
|
+
try {
|
|
88
|
+
listener(id, payload);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.warn('[superdoc-macros] command listener threw', error);
|
|
92
|
+
}
|
|
61
93
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return originalExecuteAsync.call(wrapped, id, payload);
|
|
94
|
+
})
|
|
95
|
+
.catch(() => undefined); // a thrown command is a failure — nothing to record.
|
|
96
|
+
return result;
|
|
67
97
|
};
|
|
68
98
|
}
|
|
69
|
-
/* Typing: beforeinput on the container, capture phase.
|
|
99
|
+
/* Typing: beforeinput on the container, capture phase.
|
|
100
|
+
|
|
101
|
+
IME composition is folded to a single event: while composing, the
|
|
102
|
+
engine fires insertCompositionText repeatedly with the growing
|
|
103
|
+
candidate text, and mapping each one would record the word once per
|
|
104
|
+
keystroke. The events are suppressed during composition and the final
|
|
105
|
+
text is emitted once, from compositionend. */
|
|
106
|
+
let composing = false;
|
|
107
|
+
const onCompositionStart = () => {
|
|
108
|
+
composing = true;
|
|
109
|
+
};
|
|
110
|
+
const onCompositionEnd = (event) => {
|
|
111
|
+
composing = false;
|
|
112
|
+
const data = event.data;
|
|
113
|
+
if (typeof data === 'string' && data.length > 0)
|
|
114
|
+
emitInput({ kind: 'insert-text', text: data });
|
|
115
|
+
};
|
|
70
116
|
const onBeforeInput = (event) => {
|
|
71
117
|
const input = event;
|
|
72
118
|
let mapped = null;
|
|
73
119
|
switch (input.inputType) {
|
|
74
120
|
case 'insertText':
|
|
75
121
|
case 'insertCompositionText':
|
|
122
|
+
if (composing)
|
|
123
|
+
break; // the final text arrives from compositionend.
|
|
76
124
|
if (typeof input.data === 'string' && input.data.length > 0) {
|
|
77
125
|
mapped = { kind: 'insert-text', text: input.data };
|
|
78
126
|
}
|
|
79
127
|
break;
|
|
128
|
+
// Paste, drop and spellcheck replacement carry their text either in
|
|
129
|
+
// `data` or in a dataTransfer — without this branch a recording
|
|
130
|
+
// silently missed everything the user pasted.
|
|
131
|
+
case 'insertFromPaste':
|
|
132
|
+
case 'insertFromDrop':
|
|
133
|
+
case 'insertReplacementText': {
|
|
134
|
+
const text = typeof input.data === 'string' && input.data.length > 0
|
|
135
|
+
? input.data
|
|
136
|
+
: input.dataTransfer?.getData('text/plain') ?? '';
|
|
137
|
+
if (text.length > 0)
|
|
138
|
+
mapped = { kind: 'insert-text', text };
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
80
141
|
case 'insertParagraph':
|
|
81
142
|
mapped = { kind: 'insert-paragraph' };
|
|
82
143
|
break;
|
|
@@ -89,29 +150,32 @@ export function createSuperdocHost(options) {
|
|
|
89
150
|
default:
|
|
90
151
|
break;
|
|
91
152
|
}
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
for (const listener of inputListeners) {
|
|
95
|
-
try {
|
|
96
|
-
listener(mapped);
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
console.warn('[superdoc-macros] input listener threw', error);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
153
|
+
if (mapped)
|
|
154
|
+
emitInput(mapped);
|
|
102
155
|
};
|
|
103
156
|
container?.addEventListener('beforeinput', onBeforeInput, true);
|
|
104
|
-
|
|
157
|
+
container?.addEventListener('compositionstart', onCompositionStart, true);
|
|
158
|
+
container?.addEventListener('compositionend', onCompositionEnd, true);
|
|
159
|
+
/**
|
|
160
|
+
* `failed: true` means the engine call itself threw — as opposed to a
|
|
161
|
+
* clean "no selection" answer. Callers that write relative to the caret
|
|
162
|
+
* must fail closed on it: falling back to "no target" would send the text
|
|
163
|
+
* to the end of the document, far from where the user is looking.
|
|
164
|
+
*/
|
|
165
|
+
async function readSelectionDetailed(includeText) {
|
|
105
166
|
const current = doc()?.selection?.current;
|
|
106
167
|
if (typeof current !== 'function')
|
|
107
|
-
return emptySelection();
|
|
168
|
+
return { snapshot: emptySelection(), failed: false };
|
|
108
169
|
let info;
|
|
109
170
|
try {
|
|
110
171
|
info = await current(includeText ? { includeText: true } : undefined);
|
|
111
172
|
}
|
|
112
173
|
catch {
|
|
113
|
-
return emptySelection();
|
|
174
|
+
return { snapshot: emptySelection(), failed: true };
|
|
114
175
|
}
|
|
176
|
+
return { snapshot: parseSelectionInfo(info), failed: false };
|
|
177
|
+
}
|
|
178
|
+
function parseSelectionInfo(info) {
|
|
115
179
|
if (!info || typeof info !== 'object')
|
|
116
180
|
return emptySelection();
|
|
117
181
|
const segments = Array.isArray(info.target?.segments) ? info.target.segments : [];
|
|
@@ -168,7 +232,11 @@ export function createSuperdocHost(options) {
|
|
|
168
232
|
const insert = doc()?.insert;
|
|
169
233
|
if (typeof insert === 'function') {
|
|
170
234
|
// Without a target the insertion falls to the end of the document — so the target comes from the live selection.
|
|
171
|
-
const snapshot = await
|
|
235
|
+
const { snapshot, failed: selectionFailed } = await readSelectionDetailed(false);
|
|
236
|
+
// A failed read is not "no selection": inserting without a target
|
|
237
|
+
// would land the text at the end of the document. Fail closed.
|
|
238
|
+
if (selectionFailed)
|
|
239
|
+
return failed(macroMessages().selectionUnavailable, 'selection-read-failed');
|
|
172
240
|
try {
|
|
173
241
|
const receipt = await insert({
|
|
174
242
|
value: text,
|
|
@@ -216,8 +284,27 @@ export function createSuperdocHost(options) {
|
|
|
216
284
|
return failed(error instanceof Error ? error.message : macroMessages().deleteFailed, 'threw');
|
|
217
285
|
}
|
|
218
286
|
},
|
|
219
|
-
|
|
220
|
-
|
|
287
|
+
async deleteForward(count) {
|
|
288
|
+
const pm = view();
|
|
289
|
+
if (!pm)
|
|
290
|
+
return failed(macroMessages().deletionUnavailable, 'view-unavailable');
|
|
291
|
+
try {
|
|
292
|
+
const { from } = pm.state.selection;
|
|
293
|
+
const size = pm.state.doc.content.size;
|
|
294
|
+
const end = Math.min(size, from + Math.max(0, Math.trunc(count)));
|
|
295
|
+
if (end === from)
|
|
296
|
+
return { ok: true };
|
|
297
|
+
const tr = pm.state.tr;
|
|
298
|
+
tr.delete(from, end);
|
|
299
|
+
pm.dispatch(tr);
|
|
300
|
+
return { ok: true };
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return failed(error instanceof Error ? error.message : macroMessages().deleteFailed, 'threw');
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
async getSelection(options) {
|
|
307
|
+
return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
|
|
221
308
|
},
|
|
222
309
|
async replaceAll(query, replacement) {
|
|
223
310
|
const handle = search();
|
|
@@ -278,6 +365,8 @@ export function createSuperdocHost(options) {
|
|
|
278
365
|
if (wrapped && originalExecuteAsync)
|
|
279
366
|
wrapped.executeAsync = originalExecuteAsync;
|
|
280
367
|
container?.removeEventListener('beforeinput', onBeforeInput, true);
|
|
368
|
+
container?.removeEventListener('compositionstart', onCompositionStart, true);
|
|
369
|
+
container?.removeEventListener('compositionend', onCompositionEnd, true);
|
|
281
370
|
commandListeners.clear();
|
|
282
371
|
inputListeners.clear();
|
|
283
372
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { MacroHost, MacroOutcome, MacroStep, RecordedMacro, SavedScript, SelectionSnapshot, Snippet, TextInputEvent, } from './types.js';
|
|
2
|
-
export { MacroKit, type MacroKitOptions } from './manager.js';
|
|
2
|
+
export { MacroKit, type MacroKitOptions, type ShortcutValidation } from './manager.js';
|
|
3
3
|
export { ENGLISH_MESSAGES, HEBREW_MESSAGES, setMacroMessages, type MacroMessages, } from './messages.js';
|
|
4
4
|
export { createSuperdocHost, type SuperdocHostOptions, type SuperdocLike, type SuperdocMacroHost } from './host/superdoc-host.js';
|
|
5
5
|
export { createMacroApi, MacroError, type MacroApi, type MacroBridge, type ScriptSelection } from './scripting/macro-api.js';
|
|
@@ -9,5 +9,5 @@ export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/r
|
|
|
9
9
|
export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
|
|
10
10
|
export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
|
|
11
11
|
export { AutoText, type AutoTextOptions } from './snippets/autotext.js';
|
|
12
|
-
export { parseShortcut, eventMatches, bindShortcuts, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget } from './shortcuts.js';
|
|
13
|
-
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
|
12
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
|
13
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
package/dist/index.js
CHANGED
|
@@ -7,5 +7,5 @@ export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scri
|
|
|
7
7
|
export { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
8
8
|
export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.js';
|
|
9
9
|
export { AutoText } from './snippets/autotext.js';
|
|
10
|
-
export { parseShortcut, eventMatches, bindShortcuts } from './shortcuts.js';
|
|
11
|
-
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, } from './storage.js';
|
|
10
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
|
|
11
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, } from './storage.js';
|
package/dist/manager.d.ts
CHANGED
|
@@ -31,7 +31,20 @@ export interface MacroKitOptions {
|
|
|
31
31
|
autoText?: Omit<AutoTextOptions, 'onExpand' | 'onError'> & AutoTextOptions;
|
|
32
32
|
/** Run log for `api.log`. */
|
|
33
33
|
onLog?: MacroApiOptions['onLog'];
|
|
34
|
+
/**
|
|
35
|
+
* Shortcuts the host already owns (e.g. its ribbon registry). A saved
|
|
36
|
+
* binding that collides with any of these is rejected — otherwise the
|
|
37
|
+
* macro binding, attached in the capture phase, would silently shadow an
|
|
38
|
+
* editor shortcut. Unparseable entries are ignored.
|
|
39
|
+
*/
|
|
40
|
+
reservedShortcuts?: readonly string[];
|
|
34
41
|
}
|
|
42
|
+
export type ShortcutValidation = {
|
|
43
|
+
ok: true;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
35
48
|
export declare class MacroKit {
|
|
36
49
|
private readonly host;
|
|
37
50
|
private readonly storage;
|
|
@@ -41,8 +54,20 @@ export declare class MacroKit {
|
|
|
41
54
|
private state;
|
|
42
55
|
private readonly recorder;
|
|
43
56
|
private readonly autoText;
|
|
57
|
+
private readonly reservedSignatures;
|
|
44
58
|
private running;
|
|
45
59
|
constructor(options: MacroKitOptions);
|
|
60
|
+
/**
|
|
61
|
+
* Whether a shortcut is acceptable for a saved binding: parseable, carries
|
|
62
|
+
* a real modifier, not reserved by the host, and not already used by
|
|
63
|
+
* another saved item (`excludeId` skips the item being edited). Empty or
|
|
64
|
+
* undefined means "no shortcut" and is fine. The save paths enforce this;
|
|
65
|
+
* UIs call it directly to show the message before saving.
|
|
66
|
+
*/
|
|
67
|
+
validateShortcut(shortcut: string | undefined, excludeId?: string): ShortcutValidation;
|
|
68
|
+
private findShortcutOwner;
|
|
69
|
+
/** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
|
|
70
|
+
private requireValidShortcut;
|
|
46
71
|
listScripts(): readonly SavedScript[];
|
|
47
72
|
saveScript(input: {
|
|
48
73
|
id?: string;
|
package/dist/manager.js
CHANGED
|
@@ -13,7 +13,8 @@ import { createIframeRunner } from './scripting/iframe-runner.js';
|
|
|
13
13
|
import { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
14
14
|
import { AutoText } from './snippets/autotext.js';
|
|
15
15
|
import { expandSnippet } from './snippets/snippets.js';
|
|
16
|
-
import { bindShortcuts } from './shortcuts.js';
|
|
16
|
+
import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
17
|
+
import { MacroError } from './scripting/macro-api.js';
|
|
17
18
|
import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
|
|
18
19
|
import { macroMessages } from './messages.js';
|
|
19
20
|
let idCounter = 0;
|
|
@@ -35,6 +36,7 @@ export class MacroKit {
|
|
|
35
36
|
state;
|
|
36
37
|
recorder;
|
|
37
38
|
autoText;
|
|
39
|
+
reservedSignatures;
|
|
38
40
|
running = false;
|
|
39
41
|
constructor(options) {
|
|
40
42
|
this.host = options.host;
|
|
@@ -47,12 +49,71 @@ export class MacroKit {
|
|
|
47
49
|
this.state = this.storage.load() ?? emptyState();
|
|
48
50
|
this.recorder = new MacroRecorder(this.host);
|
|
49
51
|
this.autoText = new AutoText(this.host, () => this.state.snippets, options.autoText);
|
|
52
|
+
const reserved = new Set();
|
|
53
|
+
for (const shortcut of options.reservedShortcuts ?? []) {
|
|
54
|
+
const parsed = parseShortcut(shortcut);
|
|
55
|
+
if (parsed)
|
|
56
|
+
for (const signature of shortcutSignatures(parsed))
|
|
57
|
+
reserved.add(signature);
|
|
58
|
+
}
|
|
59
|
+
this.reservedSignatures = reserved;
|
|
60
|
+
}
|
|
61
|
+
/* ---------- Shortcut validation ---------- */
|
|
62
|
+
/**
|
|
63
|
+
* Whether a shortcut is acceptable for a saved binding: parseable, carries
|
|
64
|
+
* a real modifier, not reserved by the host, and not already used by
|
|
65
|
+
* another saved item (`excludeId` skips the item being edited). Empty or
|
|
66
|
+
* undefined means "no shortcut" and is fine. The save paths enforce this;
|
|
67
|
+
* UIs call it directly to show the message before saving.
|
|
68
|
+
*/
|
|
69
|
+
validateShortcut(shortcut, excludeId) {
|
|
70
|
+
const trimmed = shortcut?.trim();
|
|
71
|
+
if (!trimmed)
|
|
72
|
+
return { ok: true };
|
|
73
|
+
const parsed = parseShortcut(trimmed);
|
|
74
|
+
if (!parsed)
|
|
75
|
+
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
76
|
+
if (!hasBindingModifier(parsed))
|
|
77
|
+
return { ok: false, message: macroMessages().shortcutNeedsModifier };
|
|
78
|
+
const signatures = shortcutSignatures(parsed);
|
|
79
|
+
if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
|
|
80
|
+
return { ok: false, message: macroMessages().shortcutReserved };
|
|
81
|
+
}
|
|
82
|
+
const owner = this.findShortcutOwner(signatures, excludeId);
|
|
83
|
+
if (owner)
|
|
84
|
+
return { ok: false, message: macroMessages().shortcutTaken(owner) };
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
findShortcutOwner(signatures, excludeId) {
|
|
88
|
+
const items = [
|
|
89
|
+
...this.state.scripts,
|
|
90
|
+
...this.state.recordings,
|
|
91
|
+
...this.state.snippets,
|
|
92
|
+
];
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
if (!item.shortcut || item.id === excludeId)
|
|
95
|
+
continue;
|
|
96
|
+
const parsed = parseShortcut(item.shortcut);
|
|
97
|
+
if (!parsed)
|
|
98
|
+
continue;
|
|
99
|
+
const existing = shortcutSignatures(parsed);
|
|
100
|
+
if (existing.some((signature) => signatures.includes(signature)))
|
|
101
|
+
return item.name;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
|
|
106
|
+
requireValidShortcut(shortcut, excludeId) {
|
|
107
|
+
const validation = this.validateShortcut(shortcut, excludeId);
|
|
108
|
+
if (!validation.ok)
|
|
109
|
+
throw new MacroError(validation.message, 'invalid-shortcut');
|
|
50
110
|
}
|
|
51
111
|
/* ---------- Scripts ---------- */
|
|
52
112
|
listScripts() {
|
|
53
113
|
return this.state.scripts;
|
|
54
114
|
}
|
|
55
115
|
saveScript(input) {
|
|
116
|
+
this.requireValidShortcut(input.shortcut, input.id);
|
|
56
117
|
const script = {
|
|
57
118
|
id: input.id ?? newId(),
|
|
58
119
|
name: input.name,
|
|
@@ -101,6 +162,7 @@ export class MacroKit {
|
|
|
101
162
|
}
|
|
102
163
|
/** Stops and saves. `null` when no step was recorded — there is nothing to save. */
|
|
103
164
|
stopRecording(name, shortcut) {
|
|
165
|
+
this.requireValidShortcut(shortcut);
|
|
104
166
|
const steps = this.recorder.stop();
|
|
105
167
|
if (steps.length === 0)
|
|
106
168
|
return null;
|
|
@@ -131,6 +193,8 @@ export class MacroKit {
|
|
|
131
193
|
const recording = this.state.recordings.find((entry) => entry.id === input.id);
|
|
132
194
|
if (!recording)
|
|
133
195
|
return null;
|
|
196
|
+
if (input.shortcut !== undefined)
|
|
197
|
+
this.requireValidShortcut(input.shortcut, input.id);
|
|
134
198
|
if (input.name !== undefined)
|
|
135
199
|
recording.name = input.name;
|
|
136
200
|
if (input.shortcut !== undefined) {
|
|
@@ -168,6 +232,7 @@ export class MacroKit {
|
|
|
168
232
|
return this.state.snippets;
|
|
169
233
|
}
|
|
170
234
|
saveSnippet(input) {
|
|
235
|
+
this.requireValidShortcut(input.shortcut, input.id);
|
|
171
236
|
const snippet = {
|
|
172
237
|
id: input.id ?? newId(),
|
|
173
238
|
name: input.name,
|
package/dist/messages.d.ts
CHANGED
|
@@ -27,14 +27,19 @@ export interface MacroMessages {
|
|
|
27
27
|
syntaxError: (detail: string) => string;
|
|
28
28
|
timedOut: (seconds: number) => string;
|
|
29
29
|
callLimitExceeded: (limit: number) => string;
|
|
30
|
-
|
|
30
|
+
macroStopped: string;
|
|
31
31
|
scriptNotFound: string;
|
|
32
32
|
recordingNotFound: string;
|
|
33
33
|
snippetNotFound: string;
|
|
34
34
|
cannotRunWhileRecording: string;
|
|
35
35
|
anotherMacroRunning: string;
|
|
36
36
|
invalidImport: string;
|
|
37
|
+
shortcutInvalid: string;
|
|
38
|
+
shortcutNeedsModifier: string;
|
|
39
|
+
shortcutReserved: string;
|
|
40
|
+
shortcutTaken: (ownerName: string) => string;
|
|
37
41
|
noDocument: string;
|
|
42
|
+
selectionUnavailable: string;
|
|
38
43
|
unknownCommand: (id: string) => string;
|
|
39
44
|
actionFailed: string;
|
|
40
45
|
deletionUnavailable: string;
|
package/dist/messages.js
CHANGED
|
@@ -27,14 +27,19 @@ export const ENGLISH_MESSAGES = {
|
|
|
27
27
|
syntaxError: (detail) => `Macro syntax error: ${detail}`,
|
|
28
28
|
timedOut: (seconds) => `The macro did not finish within ${seconds} seconds and was stopped`,
|
|
29
29
|
callLimitExceeded: (limit) => `The macro exceeded the API call limit (${limit}) and was stopped`,
|
|
30
|
-
|
|
30
|
+
macroStopped: 'The macro was stopped — the call was not executed',
|
|
31
31
|
scriptNotFound: 'Macro not found',
|
|
32
32
|
recordingNotFound: 'Recording not found',
|
|
33
33
|
snippetNotFound: 'Snippet not found',
|
|
34
34
|
cannotRunWhileRecording: 'Cannot run a macro while recording',
|
|
35
35
|
anotherMacroRunning: 'Another macro is still running',
|
|
36
36
|
invalidImport: 'The file is not a valid macro export',
|
|
37
|
+
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
38
|
+
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
39
|
+
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
40
|
+
shortcutTaken: (ownerName) => `This shortcut is already used by "${ownerName}"`,
|
|
37
41
|
noDocument: 'No document is open',
|
|
42
|
+
selectionUnavailable: 'The caret position could not be read — nothing was inserted',
|
|
38
43
|
unknownCommand: (id) => `The engine does not recognize the command ${id}`,
|
|
39
44
|
actionFailed: 'The operation failed',
|
|
40
45
|
deletionUnavailable: 'Deletion is not available in this document',
|
|
@@ -53,14 +58,19 @@ export const HEBREW_MESSAGES = {
|
|
|
53
58
|
syntaxError: (detail) => `שגיאת תחביר במאקרו: ${detail}`,
|
|
54
59
|
timedOut: (seconds) => `המאקרו לא הסתיים תוך ${seconds} שניות ונעצר`,
|
|
55
60
|
callLimitExceeded: (limit) => `המאקרו חצה את תקרת הקריאות (${limit}) ונעצר`,
|
|
56
|
-
|
|
61
|
+
macroStopped: 'המאקרו נעצר — הקריאה לא בוצעה',
|
|
57
62
|
scriptNotFound: 'המאקרו לא נמצא',
|
|
58
63
|
recordingNotFound: 'ההקלטה לא נמצאה',
|
|
59
64
|
snippetNotFound: 'הקטע לא נמצא',
|
|
60
65
|
cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
|
|
61
66
|
anotherMacroRunning: 'מאקרו אחר עדיין רץ',
|
|
62
67
|
invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
|
|
68
|
+
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
69
|
+
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
70
|
+
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
71
|
+
shortcutTaken: (ownerName) => `הקיצור כבר בשימוש של "${ownerName}"`,
|
|
63
72
|
noDocument: 'אין מסמך פתוח',
|
|
73
|
+
selectionUnavailable: 'קריאת מיקום הסמן נכשלה — לא הוכנס דבר',
|
|
64
74
|
unknownCommand: (id) => `הפקודה ${id} אינה מוכרת למנוע`,
|
|
65
75
|
actionFailed: 'הפעולה נכשלה',
|
|
66
76
|
deletionUnavailable: 'מחיקה אינה זמינה במסמך הזה',
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
+
* positions.
|
|
4
|
+
*
|
|
5
|
+
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
+
* positions) breaks the moment the document differs from what it was at
|
|
7
|
+
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
+
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
+
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
+
* shareable: the steps are plain JSON.
|
|
11
|
+
*
|
|
12
|
+
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
+
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
+
*/
|
|
1
15
|
import type { MacroHost, MacroStep } from '../types.js';
|
|
2
16
|
export interface RecorderOptions {
|
|
3
17
|
/** Command filter. The default records everything except undo/redo. */
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
-
* positions.
|
|
4
|
-
*
|
|
5
|
-
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
-
* positions) breaks the moment the document differs from what it was at
|
|
7
|
-
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
-
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
-
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
-
* shareable: the steps are plain JSON.
|
|
11
|
-
*
|
|
12
|
-
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
-
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
-
*/
|
|
15
|
-
import { macroMessages } from '../messages.js';
|
|
16
1
|
const DEFAULT_MAX_STEPS = 5_000;
|
|
17
2
|
/** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
|
|
18
3
|
function defaultShouldRecord(id) {
|
|
@@ -124,8 +109,7 @@ async function runStep(host, step) {
|
|
|
124
109
|
case 'delete-backward':
|
|
125
110
|
return host.deleteBackward(step.count);
|
|
126
111
|
case 'delete-forward':
|
|
127
|
-
|
|
128
|
-
return { ok: false, message: macroMessages().deleteForwardUnsupported, reason: 'unsupported-step' };
|
|
112
|
+
return host.deleteForward(step.count);
|
|
129
113
|
}
|
|
130
114
|
}
|
|
131
115
|
export async function replayMacro(host, steps, options = {}) {
|
|
@@ -2,4 +2,17 @@ import type { MacroBridge } from './macro-api.js';
|
|
|
2
2
|
import { type MacroRunner } from './runner.js';
|
|
3
3
|
/** Wraps a bridge with a call cap. Exposed so both runners share the same enforcement. */
|
|
4
4
|
export declare function limitCalls(bridge: MacroBridge, maxCalls: number): MacroBridge;
|
|
5
|
+
/**
|
|
6
|
+
* Wraps a bridge with a kill switch. After `revoke()` every new call is
|
|
7
|
+
* rejected — so a script that keeps running past its timeout (the eval
|
|
8
|
+
* runner cannot stop it) can no longer touch the document.
|
|
9
|
+
*
|
|
10
|
+
* What this cannot do: abort a host call that already reached the engine.
|
|
11
|
+
* The engine's public surfaces expose no cancellation, so an in-flight
|
|
12
|
+
* operation completes; what is guaranteed is that nothing *new* starts.
|
|
13
|
+
*/
|
|
14
|
+
export declare function revocable(bridge: MacroBridge): {
|
|
15
|
+
bridge: MacroBridge;
|
|
16
|
+
revoke: () => void;
|
|
17
|
+
};
|
|
5
18
|
export declare function createEvalRunner(): MacroRunner;
|
|
@@ -29,6 +29,32 @@ export function limitCalls(bridge, maxCalls) {
|
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Wraps a bridge with a kill switch. After `revoke()` every new call is
|
|
34
|
+
* rejected — so a script that keeps running past its timeout (the eval
|
|
35
|
+
* runner cannot stop it) can no longer touch the document.
|
|
36
|
+
*
|
|
37
|
+
* What this cannot do: abort a host call that already reached the engine.
|
|
38
|
+
* The engine's public surfaces expose no cancellation, so an in-flight
|
|
39
|
+
* operation completes; what is guaranteed is that nothing *new* starts.
|
|
40
|
+
*/
|
|
41
|
+
export function revocable(bridge) {
|
|
42
|
+
let revoked = false;
|
|
43
|
+
return {
|
|
44
|
+
revoke: () => {
|
|
45
|
+
revoked = true;
|
|
46
|
+
},
|
|
47
|
+
bridge: {
|
|
48
|
+
api: bridge.api,
|
|
49
|
+
callCount: bridge.callCount,
|
|
50
|
+
call(method, args) {
|
|
51
|
+
if (revoked)
|
|
52
|
+
return Promise.reject(new Error(macroMessages().macroStopped));
|
|
53
|
+
return bridge.call(method, args);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
32
58
|
/** An api proxy that routes everything through `bridge.call`, so the cap applies here too. */
|
|
33
59
|
function apiThroughBridge(bridge) {
|
|
34
60
|
return new Proxy({}, {
|
|
@@ -43,7 +69,9 @@ export function createEvalRunner() {
|
|
|
43
69
|
return {
|
|
44
70
|
async run(source, bridge, options = {}) {
|
|
45
71
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
46
|
-
|
|
72
|
+
// The revocable wrapper is what contains a timed-out script: eval cannot
|
|
73
|
+
// stop it from running, but it can no longer reach the document.
|
|
74
|
+
const { bridge: guarded, revoke } = revocable(limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS));
|
|
47
75
|
let fn;
|
|
48
76
|
try {
|
|
49
77
|
fn = new AsyncFunction('api', `"use strict";\n${source}`);
|
|
@@ -61,7 +89,7 @@ export function createEvalRunner() {
|
|
|
61
89
|
});
|
|
62
90
|
const run = (async () => {
|
|
63
91
|
try {
|
|
64
|
-
const value = await fn(apiThroughBridge(
|
|
92
|
+
const value = await fn(apiThroughBridge(guarded));
|
|
65
93
|
return { ok: true, value };
|
|
66
94
|
}
|
|
67
95
|
catch (error) {
|
|
@@ -77,6 +105,7 @@ export function createEvalRunner() {
|
|
|
77
105
|
}
|
|
78
106
|
finally {
|
|
79
107
|
clearTimeout(timer);
|
|
108
|
+
revoke();
|
|
80
109
|
}
|
|
81
110
|
},
|
|
82
111
|
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* `ScriptSelection`).
|
|
17
17
|
*/
|
|
18
18
|
import { macroMessages } from '../messages.js';
|
|
19
|
-
import { limitCalls } from './eval-runner.js';
|
|
19
|
+
import { limitCalls, revocable } from './eval-runner.js';
|
|
20
20
|
import { DEFAULT_MAX_API_CALLS, DEFAULT_TIMEOUT_MS, } from './runner.js';
|
|
21
21
|
/** Protocol marker, so the messages cannot collide with others on the page. */
|
|
22
22
|
export const PROTOCOL_MARK = '__otzariaMacro';
|
|
@@ -109,18 +109,29 @@ export function createIframeRunner(doc = document) {
|
|
|
109
109
|
return {
|
|
110
110
|
run(source, bridge, options = {}) {
|
|
111
111
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
112
|
-
|
|
112
|
+
// The revocable wrapper closes a small race: a call message that was
|
|
113
|
+
// already queued when the run finished must not dispatch to the host
|
|
114
|
+
// after the iframe is gone.
|
|
115
|
+
const { bridge: guarded, revoke } = revocable(limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS));
|
|
113
116
|
return new Promise((resolve) => {
|
|
114
117
|
const iframe = doc.createElement('iframe');
|
|
115
118
|
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
116
119
|
iframe.style.display = 'none';
|
|
117
|
-
|
|
120
|
+
// The CSP closes the sandbox's remaining hole: an opaque-origin iframe
|
|
121
|
+
// cannot reach the app, but it can still fetch the public internet.
|
|
122
|
+
// `default-src 'none'` blocks fetch/XHR/WebSocket/resources inside it;
|
|
123
|
+
// only the inline bootstrap (and the AsyncFunction it compiles) runs.
|
|
124
|
+
iframe.srcdoc =
|
|
125
|
+
`<!doctype html><meta charset="utf-8">` +
|
|
126
|
+
`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'">` +
|
|
127
|
+
`<script>${SANDBOX_BOOTSTRAP}</script>`;
|
|
118
128
|
let settled = false;
|
|
119
129
|
let timer;
|
|
120
130
|
const finish = (result) => {
|
|
121
131
|
if (settled)
|
|
122
132
|
return;
|
|
123
133
|
settled = true;
|
|
134
|
+
revoke();
|
|
124
135
|
clearTimeout(timer);
|
|
125
136
|
removeEventListener('message', onMessage);
|
|
126
137
|
iframe.remove();
|
|
@@ -139,7 +150,7 @@ export function createIframeRunner(doc = document) {
|
|
|
139
150
|
}
|
|
140
151
|
if (data.kind === 'call') {
|
|
141
152
|
const { id, method, args } = data;
|
|
142
|
-
|
|
153
|
+
guarded
|
|
143
154
|
.call(method, args)
|
|
144
155
|
.then((value) => {
|
|
145
156
|
iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'result', id, ok: true, value: toCloneSafe(value) }, '*');
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -25,6 +25,18 @@ export interface KeyEventLike {
|
|
|
25
25
|
}
|
|
26
26
|
export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
|
|
27
27
|
export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
|
30
|
+
* Meta at runtime, so it expands to both — a `Mod+K` binding collides with
|
|
31
|
+
* `Ctrl+K` and with `Meta+K`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shortcutSignatures(parsed: ParsedShortcut): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Whether the shortcut is acceptable as a *saved binding*: it must carry a
|
|
36
|
+
* real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
|
|
37
|
+
* typing, and Shift alone is just an uppercase letter.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hasBindingModifier(parsed: ParsedShortcut): boolean;
|
|
28
40
|
export interface ShortcutBinding {
|
|
29
41
|
shortcut: string;
|
|
30
42
|
run(): void | Promise<unknown>;
|
package/dist/shortcuts.js
CHANGED
|
@@ -58,6 +58,25 @@ export function eventMatches(parsed, event) {
|
|
|
58
58
|
event.shiftKey === parsed.shift &&
|
|
59
59
|
event.metaKey === parsed.meta);
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
|
63
|
+
* Meta at runtime, so it expands to both — a `Mod+K` binding collides with
|
|
64
|
+
* `Ctrl+K` and with `Meta+K`.
|
|
65
|
+
*/
|
|
66
|
+
export function shortcutSignatures(parsed) {
|
|
67
|
+
const suffix = `${parsed.alt ? 'alt+' : ''}${parsed.shift ? 'shift+' : ''}${parsed.key}`;
|
|
68
|
+
if (parsed.mod)
|
|
69
|
+
return [`ctrl+${suffix}`, `meta+${suffix}`];
|
|
70
|
+
return [`${parsed.ctrl ? 'ctrl+' : ''}${parsed.meta ? 'meta+' : ''}${suffix}`];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether the shortcut is acceptable as a *saved binding*: it must carry a
|
|
74
|
+
* real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
|
|
75
|
+
* typing, and Shift alone is just an uppercase letter.
|
|
76
|
+
*/
|
|
77
|
+
export function hasBindingModifier(parsed) {
|
|
78
|
+
return parsed.ctrl || parsed.alt || parsed.meta || parsed.mod;
|
|
79
|
+
}
|
|
61
80
|
/**
|
|
62
81
|
* Binds shortcuts to a target. `getBindings` is called on every keystroke —
|
|
63
82
|
* so the macro list can change without rebinding. Returns a dispose
|
package/dist/storage.d.ts
CHANGED
|
@@ -16,7 +16,30 @@ export interface MacroStorage {
|
|
|
16
16
|
save(state: PersistedMacroState): void;
|
|
17
17
|
}
|
|
18
18
|
export declare function emptyState(): PersistedMacroState;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Caps on imported data. Imports come from files users share with each
|
|
21
|
+
* other, so every field is validated and bounded — a malformed or oversized
|
|
22
|
+
* export must fail closed, not wedge the store or the UI.
|
|
23
|
+
*/
|
|
24
|
+
export declare const IMPORT_LIMITS: {
|
|
25
|
+
/** Whole-file size, in UTF-16 code units of the JSON string. */
|
|
26
|
+
readonly maxJsonLength: 5000000;
|
|
27
|
+
/** Per list: scripts, recordings, snippets. */
|
|
28
|
+
readonly maxItems: 500;
|
|
29
|
+
readonly maxStepsPerRecording: 5000;
|
|
30
|
+
readonly maxNameLength: 200;
|
|
31
|
+
readonly maxShortcutLength: 60;
|
|
32
|
+
readonly maxTriggerLength: 60;
|
|
33
|
+
/** Snippet text and single recorded insert-text step. */
|
|
34
|
+
readonly maxTextLength: 100000;
|
|
35
|
+
readonly maxSourceLength: 200000;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
39
|
+
* field or oversized file — never throws, never partially accepts: one
|
|
40
|
+
* invalid item rejects the whole document, so the caller can tell the user
|
|
41
|
+
* the file is bad instead of silently importing a subset.
|
|
42
|
+
*/
|
|
20
43
|
export declare function parsePersistedState(json: string): PersistedMacroState | null;
|
|
21
44
|
export declare const DEFAULT_STORAGE_KEY = "superdoc-macros:v1";
|
|
22
45
|
/** localStorage with guards: blocked or full storage must not take the toolkit down. */
|
package/dist/storage.js
CHANGED
|
@@ -1,17 +1,105 @@
|
|
|
1
1
|
export function emptyState() {
|
|
2
2
|
return { version: 1, scripts: [], recordings: [], snippets: [] };
|
|
3
3
|
}
|
|
4
|
+
/**
|
|
5
|
+
* Caps on imported data. Imports come from files users share with each
|
|
6
|
+
* other, so every field is validated and bounded — a malformed or oversized
|
|
7
|
+
* export must fail closed, not wedge the store or the UI.
|
|
8
|
+
*/
|
|
9
|
+
export const IMPORT_LIMITS = {
|
|
10
|
+
/** Whole-file size, in UTF-16 code units of the JSON string. */
|
|
11
|
+
maxJsonLength: 5_000_000,
|
|
12
|
+
/** Per list: scripts, recordings, snippets. */
|
|
13
|
+
maxItems: 500,
|
|
14
|
+
maxStepsPerRecording: 5_000,
|
|
15
|
+
maxNameLength: 200,
|
|
16
|
+
maxShortcutLength: 60,
|
|
17
|
+
maxTriggerLength: 60,
|
|
18
|
+
/** Snippet text and single recorded insert-text step. */
|
|
19
|
+
maxTextLength: 100_000,
|
|
20
|
+
maxSourceLength: 200_000,
|
|
21
|
+
};
|
|
22
|
+
function boundedString(value, maxLength, allowEmpty = false) {
|
|
23
|
+
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
|
|
24
|
+
}
|
|
25
|
+
function optionalBoundedString(value, maxLength) {
|
|
26
|
+
return value === undefined || boundedString(value, maxLength);
|
|
27
|
+
}
|
|
28
|
+
function isValidStep(value) {
|
|
29
|
+
if (typeof value !== 'object' || value === null)
|
|
30
|
+
return false;
|
|
31
|
+
const step = value;
|
|
32
|
+
switch (step.type) {
|
|
33
|
+
case 'command':
|
|
34
|
+
// The payload is opaque engine data; the id is what replay dispatches on.
|
|
35
|
+
return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
|
|
36
|
+
case 'insert-text':
|
|
37
|
+
return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
|
|
38
|
+
case 'insert-paragraph':
|
|
39
|
+
return true;
|
|
40
|
+
case 'delete-backward':
|
|
41
|
+
case 'delete-forward':
|
|
42
|
+
return typeof step.count === 'number' && Number.isInteger(step.count) && step.count > 0 && step.count <= IMPORT_LIMITS.maxTextLength;
|
|
43
|
+
default:
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isValidScript(value) {
|
|
48
|
+
if (typeof value !== 'object' || value === null)
|
|
49
|
+
return false;
|
|
50
|
+
const script = value;
|
|
51
|
+
return (boundedString(script.id, IMPORT_LIMITS.maxNameLength) &&
|
|
52
|
+
boundedString(script.name, IMPORT_LIMITS.maxNameLength) &&
|
|
53
|
+
boundedString(script.source, IMPORT_LIMITS.maxSourceLength, true) &&
|
|
54
|
+
optionalBoundedString(script.shortcut, IMPORT_LIMITS.maxShortcutLength));
|
|
55
|
+
}
|
|
56
|
+
function isValidRecording(value) {
|
|
57
|
+
if (typeof value !== 'object' || value === null)
|
|
58
|
+
return false;
|
|
59
|
+
const recording = value;
|
|
60
|
+
return (recording.version === 1 &&
|
|
61
|
+
boundedString(recording.id, IMPORT_LIMITS.maxNameLength) &&
|
|
62
|
+
boundedString(recording.name, IMPORT_LIMITS.maxNameLength) &&
|
|
63
|
+
optionalBoundedString(recording.createdAt, IMPORT_LIMITS.maxNameLength) &&
|
|
64
|
+
optionalBoundedString(recording.shortcut, IMPORT_LIMITS.maxShortcutLength) &&
|
|
65
|
+
Array.isArray(recording.steps) &&
|
|
66
|
+
recording.steps.length <= IMPORT_LIMITS.maxStepsPerRecording &&
|
|
67
|
+
recording.steps.every(isValidStep));
|
|
68
|
+
}
|
|
69
|
+
function isValidSnippet(value) {
|
|
70
|
+
if (typeof value !== 'object' || value === null)
|
|
71
|
+
return false;
|
|
72
|
+
const snippet = value;
|
|
73
|
+
return (boundedString(snippet.id, IMPORT_LIMITS.maxNameLength) &&
|
|
74
|
+
boundedString(snippet.name, IMPORT_LIMITS.maxNameLength) &&
|
|
75
|
+
boundedString(snippet.text, IMPORT_LIMITS.maxTextLength, true) &&
|
|
76
|
+
optionalBoundedString(snippet.trigger, IMPORT_LIMITS.maxTriggerLength) &&
|
|
77
|
+
optionalBoundedString(snippet.shortcut, IMPORT_LIMITS.maxShortcutLength));
|
|
78
|
+
}
|
|
4
79
|
function isValidState(value) {
|
|
5
80
|
if (typeof value !== 'object' || value === null)
|
|
6
81
|
return false;
|
|
7
82
|
const state = value;
|
|
8
83
|
return (state.version === 1 &&
|
|
9
84
|
Array.isArray(state.scripts) &&
|
|
85
|
+
state.scripts.length <= IMPORT_LIMITS.maxItems &&
|
|
86
|
+
state.scripts.every(isValidScript) &&
|
|
10
87
|
Array.isArray(state.recordings) &&
|
|
11
|
-
|
|
88
|
+
state.recordings.length <= IMPORT_LIMITS.maxItems &&
|
|
89
|
+
state.recordings.every(isValidRecording) &&
|
|
90
|
+
Array.isArray(state.snippets) &&
|
|
91
|
+
state.snippets.length <= IMPORT_LIMITS.maxItems &&
|
|
92
|
+
state.snippets.every(isValidSnippet));
|
|
12
93
|
}
|
|
13
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
96
|
+
* field or oversized file — never throws, never partially accepts: one
|
|
97
|
+
* invalid item rejects the whole document, so the caller can tell the user
|
|
98
|
+
* the file is bad instead of silently importing a subset.
|
|
99
|
+
*/
|
|
14
100
|
export function parsePersistedState(json) {
|
|
101
|
+
if (json.length > IMPORT_LIMITS.maxJsonLength)
|
|
102
|
+
return null;
|
|
15
103
|
try {
|
|
16
104
|
const parsed = JSON.parse(json);
|
|
17
105
|
return isValidState(parsed) ? parsed : null;
|
package/dist/types.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface MacroHost {
|
|
|
56
56
|
insertText(text: string): Promise<MacroOutcome>;
|
|
57
57
|
/** Deletes characters backwards from the caret. */
|
|
58
58
|
deleteBackward(count: number): Promise<MacroOutcome>;
|
|
59
|
+
/** Deletes characters forwards from the caret. */
|
|
60
|
+
deleteForward(count: number): Promise<MacroOutcome>;
|
|
59
61
|
/** Snapshot of the current selection. Never throws. */
|
|
60
62
|
getSelection(options?: {
|
|
61
63
|
includeText?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdoc-macros",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|