superdoc-macros 0.4.0 → 0.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 +6 -2
- package/dist/host/superdoc-host.js +39 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/manager.d.ts +58 -2
- package/dist/manager.js +245 -44
- package/dist/messages.d.ts +8 -0
- package/dist/messages.js +16 -0
- package/dist/recorder/recorder.d.ts +40 -15
- package/dist/recorder/recorder.js +116 -12
- package/dist/shortcuts.d.ts +19 -0
- package/dist/shortcuts.js +37 -1
- package/dist/snippets/autotext.d.ts +10 -1
- package/dist/snippets/autotext.js +15 -1
- package/dist/snippets/snippets.d.ts +6 -0
- package/dist/snippets/snippets.js +11 -5
- package/dist/storage.d.ts +18 -1
- package/dist/storage.js +48 -3
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,6 +82,8 @@ Honest limits: the browser offers no per-iframe memory cap, and a host call that
|
|
|
82
82
|
|
|
83
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.
|
|
84
84
|
|
|
85
|
+
**A real off switch:** `new MacroKit({ host, scriptsEnabled: false })` gates script *execution*, not just UI — `runScript`/`runSource` refuse and saved script shortcuts are not bound, so a pre-existing or imported script cannot run through any path. Recordings and snippets are unaffected.
|
|
86
|
+
|
|
85
87
|
## 2. Macro recorder
|
|
86
88
|
|
|
87
89
|
```ts
|
|
@@ -136,11 +138,13 @@ kit.importState(json, { merge: true });
|
|
|
136
138
|
|
|
137
139
|
`MacroStorage` is a two-method interface — implement it to persist to a file (e.g. a plugin workspace).
|
|
138
140
|
|
|
139
|
-
Imports are strictly validated
|
|
141
|
+
Imports are strictly validated **atomically on the final result**: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), every shortcut in the merged state must pass the same binding rules the save paths enforce (modifier required, not host-reserved, no duplicates), and any failure rejects the whole file with the current state untouched — no partial imports.
|
|
142
|
+
|
|
143
|
+
The same limits hold as a persistence invariant, transactionally: every mutation runs on a clone that must serialize under the loader's exact rules (shape, field caps, whole-file size) *and* be accepted by the storage before it becomes the state — a quota failure or an oversized save leaves memory and disk agreeing on the previous state, and can never wipe the store on the next startup. An oversized recorded paste is split into loadable steps; a recording that cannot be saved whole is refused with a message rather than saved partially.
|
|
140
144
|
|
|
141
145
|
## Shortcut safety
|
|
142
146
|
|
|
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:
|
|
147
|
+
Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save and on the merged result of every import: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must use a physically-mappable key (letters, digits, F-keys — matching is by `event.code`, so bindings survive non-Latin keyboard layouts), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved. Auto-repeat and keys mid-IME-composition never fire bindings:
|
|
144
148
|
|
|
145
149
|
```ts
|
|
146
150
|
const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
|
|
@@ -113,6 +113,25 @@ export function createSuperdocHost(options) {
|
|
|
113
113
|
if (typeof data === 'string' && data.length > 0)
|
|
114
114
|
emitInput({ kind: 'insert-text', text: data });
|
|
115
115
|
};
|
|
116
|
+
/* Caret movement: a click or a navigation key breaks the link between the
|
|
117
|
+
recently-typed characters and what actually sits before the caret.
|
|
118
|
+
Auto-text resets its buffer on this, and the recorder stops coalescing
|
|
119
|
+
across it. Reported as an input event so any MacroHost can supply it. */
|
|
120
|
+
const NAVIGATION_KEYS = new Set([
|
|
121
|
+
'ArrowLeft',
|
|
122
|
+
'ArrowRight',
|
|
123
|
+
'ArrowUp',
|
|
124
|
+
'ArrowDown',
|
|
125
|
+
'Home',
|
|
126
|
+
'End',
|
|
127
|
+
'PageUp',
|
|
128
|
+
'PageDown',
|
|
129
|
+
]);
|
|
130
|
+
const onPointerDown = () => emitInput({ kind: 'caret-moved' });
|
|
131
|
+
const onKeydown = (event) => {
|
|
132
|
+
if (NAVIGATION_KEYS.has(event.key))
|
|
133
|
+
emitInput({ kind: 'caret-moved' });
|
|
134
|
+
};
|
|
116
135
|
const onBeforeInput = (event) => {
|
|
117
136
|
const input = event;
|
|
118
137
|
let mapped = null;
|
|
@@ -156,6 +175,8 @@ export function createSuperdocHost(options) {
|
|
|
156
175
|
container?.addEventListener('beforeinput', onBeforeInput, true);
|
|
157
176
|
container?.addEventListener('compositionstart', onCompositionStart, true);
|
|
158
177
|
container?.addEventListener('compositionend', onCompositionEnd, true);
|
|
178
|
+
container?.addEventListener('pointerdown', onPointerDown, true);
|
|
179
|
+
container?.addEventListener('keydown', onKeydown, true);
|
|
159
180
|
/**
|
|
160
181
|
* `failed: true` means the engine call itself threw — as opposed to a
|
|
161
182
|
* clean "no selection" answer. Callers that write relative to the caret
|
|
@@ -306,6 +327,22 @@ export function createSuperdocHost(options) {
|
|
|
306
327
|
async getSelection(options) {
|
|
307
328
|
return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
|
|
308
329
|
},
|
|
330
|
+
async getTextBefore(count) {
|
|
331
|
+
// Auto-text's verification before it deletes: the answer must reflect
|
|
332
|
+
// the live document, so `null` (unknown) is the only honest reply when
|
|
333
|
+
// the view is unavailable — never a guess.
|
|
334
|
+
const pm = view();
|
|
335
|
+
if (!pm)
|
|
336
|
+
return null;
|
|
337
|
+
try {
|
|
338
|
+
const { from } = pm.state.selection;
|
|
339
|
+
const start = Math.max(0, from - Math.max(0, Math.trunc(count)));
|
|
340
|
+
return pm.state.doc.textBetween(start, from);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
309
346
|
async replaceAll(query, replacement) {
|
|
310
347
|
const handle = search();
|
|
311
348
|
if (!handle)
|
|
@@ -367,6 +404,8 @@ export function createSuperdocHost(options) {
|
|
|
367
404
|
container?.removeEventListener('beforeinput', onBeforeInput, true);
|
|
368
405
|
container?.removeEventListener('compositionstart', onCompositionStart, true);
|
|
369
406
|
container?.removeEventListener('compositionend', onCompositionEnd, true);
|
|
407
|
+
container?.removeEventListener('pointerdown', onPointerDown, true);
|
|
408
|
+
container?.removeEventListener('keydown', onKeydown, true);
|
|
370
409
|
commandListeners.clear();
|
|
371
410
|
inputListeners.clear();
|
|
372
411
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { createEvalRunner } from './scripting/eval-runner.js';
|
|
|
7
7
|
export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
|
|
8
8
|
export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/runner.js';
|
|
9
9
|
export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
|
|
10
|
-
export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
|
|
11
|
-
export { AutoText, type AutoTextOptions } from './snippets/autotext.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';
|
|
10
|
+
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
|
|
11
|
+
export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
|
|
12
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
|
13
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { createMacroApi, MacroError } from './scripting/macro-api.js';
|
|
|
5
5
|
export { createEvalRunner } from './scripting/eval-runner.js';
|
|
6
6
|
export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
|
|
7
7
|
export { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
8
|
-
export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.js';
|
|
8
|
+
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
|
|
9
9
|
export { AutoText } from './snippets/autotext.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';
|
|
10
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
|
|
11
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.js';
|
package/dist/manager.d.ts
CHANGED
|
@@ -38,6 +38,20 @@ export interface MacroKitOptions {
|
|
|
38
38
|
* editor shortcut. Unparseable entries are ignored.
|
|
39
39
|
*/
|
|
40
40
|
reservedShortcuts?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Whether scripted macros may *execute*. Default: true. When false the
|
|
43
|
+
* gate is real, not cosmetic: `runScript`/`runSource` refuse, and saved
|
|
44
|
+
* script shortcuts are not bound — so a pre-existing or imported script
|
|
45
|
+
* cannot run through any path. Saving and listing still work (a UI may
|
|
46
|
+
* let the user manage scripts it will not run).
|
|
47
|
+
*/
|
|
48
|
+
scriptsEnabled?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Called when a recording hits the step cap and stops on its own. The
|
|
51
|
+
* steps are kept — `stopRecording(name)` still saves them — but a UI that
|
|
52
|
+
* shows a live "recording" indicator must update it.
|
|
53
|
+
*/
|
|
54
|
+
onRecordingAutoStop?: () => void;
|
|
41
55
|
}
|
|
42
56
|
export type ShortcutValidation = {
|
|
43
57
|
ok: true;
|
|
@@ -55,6 +69,7 @@ export declare class MacroKit {
|
|
|
55
69
|
private readonly recorder;
|
|
56
70
|
private readonly autoText;
|
|
57
71
|
private readonly reservedSignatures;
|
|
72
|
+
private readonly scriptsEnabled;
|
|
58
73
|
private running;
|
|
59
74
|
constructor(options: MacroKitOptions);
|
|
60
75
|
/**
|
|
@@ -82,7 +97,12 @@ export declare class MacroKit {
|
|
|
82
97
|
get isRecording(): boolean;
|
|
83
98
|
get recordedStepCount(): number;
|
|
84
99
|
startRecording(): void;
|
|
85
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
102
|
+
* save. Throws when the recording cannot be saved *whole* (its splittable
|
|
103
|
+
* steps still exceed the loader's step cap): a silently partial macro
|
|
104
|
+
* would replay something other than what the user did.
|
|
105
|
+
*/
|
|
86
106
|
stopRecording(name: string, shortcut?: string): RecordedMacro | null;
|
|
87
107
|
cancelRecording(): void;
|
|
88
108
|
listRecordings(): readonly RecordedMacro[];
|
|
@@ -123,6 +143,12 @@ export declare class MacroKit {
|
|
|
123
143
|
* Imports JSON produced by `exportState`. With `merge: true` an imported
|
|
124
144
|
* item with an existing `id` replaces it; without merge the whole state is
|
|
125
145
|
* replaced.
|
|
146
|
+
*
|
|
147
|
+
* The check is **atomic, on the final result**: a candidate state is built
|
|
148
|
+
* first, its size limits and every shortcut in it are validated (the same
|
|
149
|
+
* rules the save paths enforce — a file cannot smuggle in what typing
|
|
150
|
+
* cannot), and only a candidate that passed in full is committed. On any
|
|
151
|
+
* failure the current state is untouched.
|
|
126
152
|
*/
|
|
127
153
|
importState(json: string, options?: {
|
|
128
154
|
merge?: boolean;
|
|
@@ -130,7 +156,37 @@ export declare class MacroKit {
|
|
|
130
156
|
ok: boolean;
|
|
131
157
|
message?: string;
|
|
132
158
|
};
|
|
159
|
+
/**
|
|
160
|
+
* Every shortcut in a candidate state, under the exact rules of
|
|
161
|
+
* `validateShortcut`: parseable, real modifier, not host-reserved, and
|
|
162
|
+
* unique within the candidate. `importState` is the only caller — the
|
|
163
|
+
* save paths enforce the same rules one item at a time.
|
|
164
|
+
*/
|
|
165
|
+
private validateStateShortcuts;
|
|
133
166
|
private guardRun;
|
|
167
|
+
/**
|
|
168
|
+
* The save-path half of the persistence invariant: field lengths that the
|
|
169
|
+
* loader would reject are refused at the door. Without this, one oversized
|
|
170
|
+
* save would make the whole store unloadable — and the next startup would
|
|
171
|
+
* silently fall back to an empty state, losing everything.
|
|
172
|
+
*/
|
|
173
|
+
private requireItemLimits;
|
|
174
|
+
/** The item-count half of the invariant. `existingId` exempts an in-place update. */
|
|
175
|
+
private requireRoom;
|
|
134
176
|
private upsert;
|
|
135
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Applies a mutation transactionally: the change runs on a clone, and the
|
|
179
|
+
* clone becomes the state only through `adopt` — validation and storage
|
|
180
|
+
* included. A failure at any stage leaves the previous state fully
|
|
181
|
+
* intact. Before this, the in-memory state mutated first and a quota
|
|
182
|
+
* failure left memory and disk silently disagreeing until the next
|
|
183
|
+
* successful save rewrote history.
|
|
184
|
+
*/
|
|
185
|
+
private commit;
|
|
186
|
+
/**
|
|
187
|
+
* The persistence invariant, in one place: a candidate becomes the state
|
|
188
|
+
* only if it serializes under the loader's exact rules (shape, field
|
|
189
|
+
* caps, whole-file size) *and* the storage actually accepted it.
|
|
190
|
+
*/
|
|
191
|
+
private adopt;
|
|
136
192
|
}
|
package/dist/manager.js
CHANGED
|
@@ -12,8 +12,9 @@ import { createEvalRunner } from './scripting/eval-runner.js';
|
|
|
12
12
|
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
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { renderSnippetForHost } from './snippets/snippets.js';
|
|
16
|
+
import { IMPORT_LIMITS, isPersistableState, serializePersistable } from './storage.js';
|
|
17
|
+
import { bindShortcuts, hasBindingModifier, isBindableKey, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
17
18
|
import { MacroError } from './scripting/macro-api.js';
|
|
18
19
|
import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
|
|
19
20
|
import { macroMessages } from './messages.js';
|
|
@@ -37,6 +38,7 @@ export class MacroKit {
|
|
|
37
38
|
recorder;
|
|
38
39
|
autoText;
|
|
39
40
|
reservedSignatures;
|
|
41
|
+
scriptsEnabled;
|
|
40
42
|
running = false;
|
|
41
43
|
constructor(options) {
|
|
42
44
|
this.host = options.host;
|
|
@@ -46,9 +48,19 @@ export class MacroKit {
|
|
|
46
48
|
const runner = options.runner ?? 'iframe';
|
|
47
49
|
this.runner =
|
|
48
50
|
runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
|
|
51
|
+
this.scriptsEnabled = options.scriptsEnabled ?? true;
|
|
49
52
|
this.state = this.storage.load() ?? emptyState();
|
|
50
|
-
this.recorder = new MacroRecorder(this.host);
|
|
51
|
-
this.autoText = new AutoText(this.host, () => this.state.snippets,
|
|
53
|
+
this.recorder = new MacroRecorder(this.host, { onAutoStop: options.onRecordingAutoStop });
|
|
54
|
+
this.autoText = new AutoText(this.host, () => this.state.snippets, {
|
|
55
|
+
...options.autoText,
|
|
56
|
+
// The recorder rewrite keeps recordings truthful: the user typed a
|
|
57
|
+
// trigger word, the document holds the expanded text, and a replay of
|
|
58
|
+
// the raw keystrokes would diverge. See applyAutoTextExpansion.
|
|
59
|
+
onExpand: (snippet, expansion) => {
|
|
60
|
+
this.recorder.applyAutoTextExpansion(expansion.trigger.length + expansion.expandChar.length, expansion.rendered + expansion.expandChar);
|
|
61
|
+
options.autoText?.onExpand?.(snippet, expansion);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
52
64
|
const reserved = new Set();
|
|
53
65
|
for (const shortcut of options.reservedShortcuts ?? []) {
|
|
54
66
|
const parsed = parseShortcut(shortcut);
|
|
@@ -75,6 +87,10 @@ export class MacroKit {
|
|
|
75
87
|
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
76
88
|
if (!hasBindingModifier(parsed))
|
|
77
89
|
return { ok: false, message: macroMessages().shortcutNeedsModifier };
|
|
90
|
+
// Only keys with a physical-code mapping (letters, digits, F-keys):
|
|
91
|
+
// matching is by event.code, so it survives a Hebrew keyboard layout.
|
|
92
|
+
if (!isBindableKey(parsed))
|
|
93
|
+
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
78
94
|
const signatures = shortcutSignatures(parsed);
|
|
79
95
|
if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
|
|
80
96
|
return { ok: false, message: macroMessages().shortcutReserved };
|
|
@@ -114,19 +130,23 @@ export class MacroKit {
|
|
|
114
130
|
}
|
|
115
131
|
saveScript(input) {
|
|
116
132
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
133
|
+
this.requireItemLimits({ name: input.name, source: input.source, shortcut: input.shortcut });
|
|
134
|
+
this.requireRoom(this.state.scripts, input.id);
|
|
117
135
|
const script = {
|
|
118
136
|
id: input.id ?? newId(),
|
|
119
137
|
name: input.name,
|
|
120
138
|
source: input.source,
|
|
121
139
|
...(input.shortcut ? { shortcut: input.shortcut } : {}),
|
|
122
140
|
};
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
return this.commit((draft) => {
|
|
142
|
+
this.upsert(draft.scripts, script);
|
|
143
|
+
return script;
|
|
144
|
+
});
|
|
126
145
|
}
|
|
127
146
|
removeScript(id) {
|
|
128
|
-
this.
|
|
129
|
-
|
|
147
|
+
this.commit((draft) => {
|
|
148
|
+
draft.scripts = draft.scripts.filter((script) => script.id !== id);
|
|
149
|
+
});
|
|
130
150
|
}
|
|
131
151
|
async runScript(id) {
|
|
132
152
|
const script = this.state.scripts.find((entry) => entry.id === id);
|
|
@@ -136,6 +156,11 @@ export class MacroKit {
|
|
|
136
156
|
}
|
|
137
157
|
/** Runs an unsaved script — e.g. from the macro editor before saving. */
|
|
138
158
|
async runSource(source) {
|
|
159
|
+
// The real gate: with scripts disabled nothing executes through any
|
|
160
|
+
// path — not a saved script, not an imported one, not its shortcut.
|
|
161
|
+
if (!this.scriptsEnabled) {
|
|
162
|
+
return { ok: false, reason: 'error', message: macroMessages().scriptsDisabled };
|
|
163
|
+
}
|
|
139
164
|
const guard = this.guardRun();
|
|
140
165
|
if (guard)
|
|
141
166
|
return guard;
|
|
@@ -160,10 +185,19 @@ export class MacroKit {
|
|
|
160
185
|
return;
|
|
161
186
|
this.recorder.start();
|
|
162
187
|
}
|
|
163
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
190
|
+
* save. Throws when the recording cannot be saved *whole* (its splittable
|
|
191
|
+
* steps still exceed the loader's step cap): a silently partial macro
|
|
192
|
+
* would replay something other than what the user did.
|
|
193
|
+
*/
|
|
164
194
|
stopRecording(name, shortcut) {
|
|
165
195
|
this.requireValidShortcut(shortcut);
|
|
166
|
-
|
|
196
|
+
this.requireItemLimits({ name, shortcut });
|
|
197
|
+
this.requireRoom(this.state.recordings);
|
|
198
|
+
const { steps, truncated } = splitOversizedSteps(this.recorder.stop());
|
|
199
|
+
if (truncated)
|
|
200
|
+
throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
|
|
167
201
|
if (steps.length === 0)
|
|
168
202
|
return null;
|
|
169
203
|
const recording = {
|
|
@@ -174,9 +208,10 @@ export class MacroKit {
|
|
|
174
208
|
...(shortcut ? { shortcut } : {}),
|
|
175
209
|
steps,
|
|
176
210
|
};
|
|
177
|
-
this.
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
return this.commit((draft) => {
|
|
212
|
+
draft.recordings.push(recording);
|
|
213
|
+
return recording;
|
|
214
|
+
});
|
|
180
215
|
}
|
|
181
216
|
cancelRecording() {
|
|
182
217
|
this.recorder.cancel();
|
|
@@ -185,26 +220,30 @@ export class MacroKit {
|
|
|
185
220
|
return this.state.recordings;
|
|
186
221
|
}
|
|
187
222
|
removeRecording(id) {
|
|
188
|
-
this.
|
|
189
|
-
|
|
223
|
+
this.commit((draft) => {
|
|
224
|
+
draft.recordings = draft.recordings.filter((recording) => recording.id !== id);
|
|
225
|
+
});
|
|
190
226
|
}
|
|
191
227
|
/** Renames a recording or edits its shortcut. `null` when the recording was not found. */
|
|
192
228
|
updateRecording(input) {
|
|
193
|
-
|
|
194
|
-
if (!recording)
|
|
229
|
+
if (!this.state.recordings.some((entry) => entry.id === input.id))
|
|
195
230
|
return null;
|
|
196
231
|
if (input.shortcut !== undefined)
|
|
197
232
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
198
233
|
if (input.name !== undefined)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
234
|
+
this.requireItemLimits({ name: input.name, shortcut: input.shortcut });
|
|
235
|
+
return this.commit((draft) => {
|
|
236
|
+
const recording = draft.recordings.find((entry) => entry.id === input.id);
|
|
237
|
+
if (input.name !== undefined)
|
|
238
|
+
recording.name = input.name;
|
|
239
|
+
if (input.shortcut !== undefined) {
|
|
240
|
+
if (input.shortcut)
|
|
241
|
+
recording.shortcut = input.shortcut;
|
|
242
|
+
else
|
|
243
|
+
delete recording.shortcut;
|
|
244
|
+
}
|
|
245
|
+
return recording;
|
|
246
|
+
});
|
|
208
247
|
}
|
|
209
248
|
async replayRecording(id, options) {
|
|
210
249
|
const recording = this.state.recordings.find((entry) => entry.id === id);
|
|
@@ -233,6 +272,13 @@ export class MacroKit {
|
|
|
233
272
|
}
|
|
234
273
|
saveSnippet(input) {
|
|
235
274
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
275
|
+
this.requireItemLimits({
|
|
276
|
+
name: input.name,
|
|
277
|
+
text: input.text,
|
|
278
|
+
trigger: input.trigger,
|
|
279
|
+
shortcut: input.shortcut,
|
|
280
|
+
});
|
|
281
|
+
this.requireRoom(this.state.snippets, input.id);
|
|
236
282
|
const snippet = {
|
|
237
283
|
id: input.id ?? newId(),
|
|
238
284
|
name: input.name,
|
|
@@ -240,20 +286,30 @@ export class MacroKit {
|
|
|
240
286
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
241
287
|
...(input.shortcut ? { shortcut: input.shortcut } : {}),
|
|
242
288
|
};
|
|
243
|
-
this.
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
return this.commit((draft) => {
|
|
290
|
+
this.upsert(draft.snippets, snippet);
|
|
291
|
+
return snippet;
|
|
292
|
+
});
|
|
246
293
|
}
|
|
247
294
|
removeSnippet(id) {
|
|
248
|
-
this.
|
|
249
|
-
|
|
295
|
+
this.commit((draft) => {
|
|
296
|
+
draft.snippets = draft.snippets.filter((snippet) => snippet.id !== id);
|
|
297
|
+
});
|
|
250
298
|
}
|
|
251
299
|
async expandSnippet(id, options) {
|
|
252
300
|
const snippet = this.state.snippets.find((entry) => entry.id === id);
|
|
253
301
|
if (!snippet)
|
|
254
302
|
return { ok: false, message: macroMessages().snippetNotFound };
|
|
255
|
-
const
|
|
256
|
-
|
|
303
|
+
const rendered = await renderSnippetForHost(this.host, snippet, options);
|
|
304
|
+
const outcome = await this.host.insertText(rendered);
|
|
305
|
+
if (!outcome.ok)
|
|
306
|
+
return { ok: false, message: outcome.message };
|
|
307
|
+
// A snippet expanded from a button or shortcut writes through the
|
|
308
|
+
// document API and fires no typing events — recorded explicitly, or a
|
|
309
|
+
// replay would silently miss text the user watched appear. The auto-text
|
|
310
|
+
// path needs nothing here: its expansion rewrites the typed tail.
|
|
311
|
+
this.recorder.recordInsert(rendered);
|
|
312
|
+
return { ok: true };
|
|
257
313
|
}
|
|
258
314
|
/** Enables auto-text (trigger + space). Returns a disable function. */
|
|
259
315
|
enableAutoText() {
|
|
@@ -274,9 +330,14 @@ export class MacroKit {
|
|
|
274
330
|
}
|
|
275
331
|
currentBindings() {
|
|
276
332
|
const bindings = [];
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
333
|
+
// Part of the scripts gate: with scripts disabled their shortcuts are
|
|
334
|
+
// not bound at all — runScript would refuse anyway, but an unbound key
|
|
335
|
+
// is better than a key that swallows the event just to show an error.
|
|
336
|
+
if (this.scriptsEnabled) {
|
|
337
|
+
for (const script of this.state.scripts) {
|
|
338
|
+
if (script.shortcut)
|
|
339
|
+
bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
|
|
340
|
+
}
|
|
280
341
|
}
|
|
281
342
|
for (const recording of this.state.recordings) {
|
|
282
343
|
if (recording.shortcut)
|
|
@@ -296,23 +357,83 @@ export class MacroKit {
|
|
|
296
357
|
* Imports JSON produced by `exportState`. With `merge: true` an imported
|
|
297
358
|
* item with an existing `id` replaces it; without merge the whole state is
|
|
298
359
|
* replaced.
|
|
360
|
+
*
|
|
361
|
+
* The check is **atomic, on the final result**: a candidate state is built
|
|
362
|
+
* first, its size limits and every shortcut in it are validated (the same
|
|
363
|
+
* rules the save paths enforce — a file cannot smuggle in what typing
|
|
364
|
+
* cannot), and only a candidate that passed in full is committed. On any
|
|
365
|
+
* failure the current state is untouched.
|
|
299
366
|
*/
|
|
300
367
|
importState(json, options = {}) {
|
|
301
368
|
const imported = parsePersistedState(json);
|
|
302
369
|
if (!imported)
|
|
303
370
|
return { ok: false, message: macroMessages().invalidImport };
|
|
371
|
+
let candidate;
|
|
304
372
|
if (options.merge) {
|
|
373
|
+
candidate = {
|
|
374
|
+
version: 1,
|
|
375
|
+
scripts: [...this.state.scripts.map((item) => ({ ...item }))],
|
|
376
|
+
recordings: [...this.state.recordings.map((item) => ({ ...item }))],
|
|
377
|
+
snippets: [...this.state.snippets.map((item) => ({ ...item }))],
|
|
378
|
+
};
|
|
305
379
|
for (const script of imported.scripts)
|
|
306
|
-
this.upsert(
|
|
380
|
+
this.upsert(candidate.scripts, script);
|
|
307
381
|
for (const recording of imported.recordings)
|
|
308
|
-
this.upsert(
|
|
382
|
+
this.upsert(candidate.recordings, recording);
|
|
309
383
|
for (const snippet of imported.snippets)
|
|
310
|
-
this.upsert(
|
|
384
|
+
this.upsert(candidate.snippets, snippet);
|
|
311
385
|
}
|
|
312
386
|
else {
|
|
313
|
-
|
|
387
|
+
candidate = imported;
|
|
388
|
+
}
|
|
389
|
+
// The merged result can exceed what each file alone respected.
|
|
390
|
+
if (!isPersistableState(candidate))
|
|
391
|
+
return { ok: false, message: macroMessages().importTooLarge };
|
|
392
|
+
const shortcutsOk = this.validateStateShortcuts(candidate);
|
|
393
|
+
if (!shortcutsOk.ok)
|
|
394
|
+
return shortcutsOk;
|
|
395
|
+
try {
|
|
396
|
+
this.adopt(candidate);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return { ok: false, message: error instanceof Error ? error.message : macroMessages().saveFailed };
|
|
400
|
+
}
|
|
401
|
+
return { ok: true };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Every shortcut in a candidate state, under the exact rules of
|
|
405
|
+
* `validateShortcut`: parseable, real modifier, not host-reserved, and
|
|
406
|
+
* unique within the candidate. `importState` is the only caller — the
|
|
407
|
+
* save paths enforce the same rules one item at a time.
|
|
408
|
+
*/
|
|
409
|
+
validateStateShortcuts(candidate) {
|
|
410
|
+
const seen = new Map();
|
|
411
|
+
const items = [
|
|
412
|
+
...candidate.scripts,
|
|
413
|
+
...candidate.recordings,
|
|
414
|
+
...candidate.snippets,
|
|
415
|
+
];
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
if (!item.shortcut)
|
|
418
|
+
continue;
|
|
419
|
+
const parsed = parseShortcut(item.shortcut);
|
|
420
|
+
if (!parsed) {
|
|
421
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
|
|
422
|
+
}
|
|
423
|
+
if (!hasBindingModifier(parsed)) {
|
|
424
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutNeedsModifier) };
|
|
425
|
+
}
|
|
426
|
+
for (const signature of shortcutSignatures(parsed)) {
|
|
427
|
+
if (this.reservedSignatures.has(signature)) {
|
|
428
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
|
|
429
|
+
}
|
|
430
|
+
const owner = seen.get(signature);
|
|
431
|
+
if (owner !== undefined) {
|
|
432
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
|
|
433
|
+
}
|
|
434
|
+
seen.set(signature, item.name);
|
|
435
|
+
}
|
|
314
436
|
}
|
|
315
|
-
this.persist();
|
|
316
437
|
return { ok: true };
|
|
317
438
|
}
|
|
318
439
|
/* ---------- Internal ---------- */
|
|
@@ -325,6 +446,40 @@ export class MacroKit {
|
|
|
325
446
|
}
|
|
326
447
|
return null;
|
|
327
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The save-path half of the persistence invariant: field lengths that the
|
|
451
|
+
* loader would reject are refused at the door. Without this, one oversized
|
|
452
|
+
* save would make the whole store unloadable — and the next startup would
|
|
453
|
+
* silently fall back to an empty state, losing everything.
|
|
454
|
+
*/
|
|
455
|
+
requireItemLimits(fields) {
|
|
456
|
+
const limits = IMPORT_LIMITS;
|
|
457
|
+
if (fields.name.length === 0)
|
|
458
|
+
throw new MacroError(macroMessages().nameRequired, 'invalid-item');
|
|
459
|
+
if (fields.name.length > limits.maxNameLength) {
|
|
460
|
+
throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
|
|
461
|
+
}
|
|
462
|
+
if (fields.shortcut !== undefined && fields.shortcut.length > limits.maxShortcutLength) {
|
|
463
|
+
throw new MacroError(macroMessages().fieldTooLong('shortcut', limits.maxShortcutLength), 'invalid-item');
|
|
464
|
+
}
|
|
465
|
+
if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
|
|
466
|
+
throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
|
|
467
|
+
}
|
|
468
|
+
if (fields.source !== undefined && fields.source.length > limits.maxSourceLength) {
|
|
469
|
+
throw new MacroError(macroMessages().fieldTooLong('source', limits.maxSourceLength), 'invalid-item');
|
|
470
|
+
}
|
|
471
|
+
if (fields.trigger !== undefined && fields.trigger.length > limits.maxTriggerLength) {
|
|
472
|
+
throw new MacroError(macroMessages().fieldTooLong('trigger', limits.maxTriggerLength), 'invalid-item');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** The item-count half of the invariant. `existingId` exempts an in-place update. */
|
|
476
|
+
requireRoom(list, existingId) {
|
|
477
|
+
if (existingId && list.some((entry) => entry.id === existingId))
|
|
478
|
+
return;
|
|
479
|
+
if (list.length >= IMPORT_LIMITS.maxItems) {
|
|
480
|
+
throw new MacroError(macroMessages().tooManyItems, 'too-many-items');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
328
483
|
upsert(list, item) {
|
|
329
484
|
const index = list.findIndex((entry) => entry.id === item.id);
|
|
330
485
|
if (index >= 0)
|
|
@@ -332,7 +487,53 @@ export class MacroKit {
|
|
|
332
487
|
else
|
|
333
488
|
list.push(item);
|
|
334
489
|
}
|
|
335
|
-
|
|
336
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Applies a mutation transactionally: the change runs on a clone, and the
|
|
492
|
+
* clone becomes the state only through `adopt` — validation and storage
|
|
493
|
+
* included. A failure at any stage leaves the previous state fully
|
|
494
|
+
* intact. Before this, the in-memory state mutated first and a quota
|
|
495
|
+
* failure left memory and disk silently disagreeing until the next
|
|
496
|
+
* successful save rewrote history.
|
|
497
|
+
*/
|
|
498
|
+
commit(mutate) {
|
|
499
|
+
const draft = JSON.parse(JSON.stringify(this.state));
|
|
500
|
+
const result = mutate(draft);
|
|
501
|
+
this.adopt(draft);
|
|
502
|
+
return result;
|
|
337
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* The persistence invariant, in one place: a candidate becomes the state
|
|
506
|
+
* only if it serializes under the loader's exact rules (shape, field
|
|
507
|
+
* caps, whole-file size) *and* the storage actually accepted it.
|
|
508
|
+
*/
|
|
509
|
+
adopt(candidate) {
|
|
510
|
+
if (serializePersistable(candidate) === null) {
|
|
511
|
+
throw new MacroError(macroMessages().saveFailed, 'invalid-state');
|
|
512
|
+
}
|
|
513
|
+
if (!this.storage.save(candidate)) {
|
|
514
|
+
throw new MacroError(macroMessages().saveFailed, 'storage-failed');
|
|
515
|
+
}
|
|
516
|
+
this.state = candidate;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* A recorded insert-text step can exceed the loader's per-step cap (one huge
|
|
521
|
+
* paste coalesces into one step). Splitting preserves the exact text while
|
|
522
|
+
* keeping the recording loadable. `truncated` reports the pathological case
|
|
523
|
+
* where even the split exceeds the loader's step limit — the caller refuses
|
|
524
|
+
* to save then, with a message: a silently partial macro is worse than no
|
|
525
|
+
* macro.
|
|
526
|
+
*/
|
|
527
|
+
function splitOversizedSteps(steps) {
|
|
528
|
+
const max = IMPORT_LIMITS.maxTextLength;
|
|
529
|
+
const split = steps.flatMap((step) => {
|
|
530
|
+
if (step.type !== 'insert-text' || step.text.length <= max)
|
|
531
|
+
return [step];
|
|
532
|
+
const chunks = [];
|
|
533
|
+
for (let offset = 0; offset < step.text.length; offset += max) {
|
|
534
|
+
chunks.push({ type: 'insert-text', text: step.text.slice(offset, offset + max) });
|
|
535
|
+
}
|
|
536
|
+
return chunks;
|
|
537
|
+
});
|
|
538
|
+
return { steps: split, truncated: split.length > IMPORT_LIMITS.maxStepsPerRecording };
|
|
338
539
|
}
|
package/dist/messages.d.ts
CHANGED
|
@@ -33,7 +33,15 @@ export interface MacroMessages {
|
|
|
33
33
|
snippetNotFound: string;
|
|
34
34
|
cannotRunWhileRecording: string;
|
|
35
35
|
anotherMacroRunning: string;
|
|
36
|
+
scriptsDisabled: string;
|
|
37
|
+
nameRequired: string;
|
|
36
38
|
invalidImport: string;
|
|
39
|
+
importRejectedShortcut: (itemName: string, detail: string) => string;
|
|
40
|
+
importTooLarge: string;
|
|
41
|
+
tooManyItems: string;
|
|
42
|
+
fieldTooLong: (field: string, max: number) => string;
|
|
43
|
+
saveFailed: string;
|
|
44
|
+
recordingTooLarge: string;
|
|
37
45
|
shortcutInvalid: string;
|
|
38
46
|
shortcutNeedsModifier: string;
|
|
39
47
|
shortcutReserved: string;
|