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 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: 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.
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
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
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
- private persist;
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 { expandSnippet } from './snippets/snippets.js';
16
- import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
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, options.autoText);
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.upsert(this.state.scripts, script);
124
- this.persist();
125
- return script;
141
+ return this.commit((draft) => {
142
+ this.upsert(draft.scripts, script);
143
+ return script;
144
+ });
126
145
  }
127
146
  removeScript(id) {
128
- this.state.scripts = this.state.scripts.filter((script) => script.id !== id);
129
- this.persist();
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
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
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
- const steps = this.recorder.stop();
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.state.recordings.push(recording);
178
- this.persist();
179
- return recording;
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.state.recordings = this.state.recordings.filter((recording) => recording.id !== id);
189
- this.persist();
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
- const recording = this.state.recordings.find((entry) => entry.id === input.id);
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
- recording.name = input.name;
200
- if (input.shortcut !== undefined) {
201
- if (input.shortcut)
202
- recording.shortcut = input.shortcut;
203
- else
204
- delete recording.shortcut;
205
- }
206
- this.persist();
207
- return recording;
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.upsert(this.state.snippets, snippet);
244
- this.persist();
245
- return snippet;
289
+ return this.commit((draft) => {
290
+ this.upsert(draft.snippets, snippet);
291
+ return snippet;
292
+ });
246
293
  }
247
294
  removeSnippet(id) {
248
- this.state.snippets = this.state.snippets.filter((snippet) => snippet.id !== id);
249
- this.persist();
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 outcome = await expandSnippet(this.host, snippet, options);
256
- return outcome.ok ? { ok: true } : { ok: false, message: outcome.message };
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
- for (const script of this.state.scripts) {
278
- if (script.shortcut)
279
- bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
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(this.state.scripts, script);
380
+ this.upsert(candidate.scripts, script);
307
381
  for (const recording of imported.recordings)
308
- this.upsert(this.state.recordings, recording);
382
+ this.upsert(candidate.recordings, recording);
309
383
  for (const snippet of imported.snippets)
310
- this.upsert(this.state.snippets, snippet);
384
+ this.upsert(candidate.snippets, snippet);
311
385
  }
312
386
  else {
313
- this.state = imported;
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
- persist() {
336
- this.storage.save(this.state);
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
  }
@@ -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;