superdoc-macros 0.4.0 → 0.5.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 +5 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/manager.d.ts +37 -0
- package/dist/manager.js +160 -10
- package/dist/messages.d.ts +6 -0
- package/dist/messages.js +12 -0
- package/dist/recorder/recorder.d.ts +27 -1
- package/dist/recorder/recorder.js +53 -7
- package/dist/snippets/autotext.d.ts +10 -1
- package/dist/snippets/autotext.js +1 -1
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +9 -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,7 +138,9 @@ 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: the save paths refuse oversized fields and full lists, an oversized recorded paste is split into loadable steps, and state that the loader would reject is never written — so a single bad save can never wipe the store on the next startup.
|
|
140
144
|
|
|
141
145
|
## Shortcut safety
|
|
142
146
|
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scri
|
|
|
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
10
|
export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
|
|
11
|
-
export { AutoText, type AutoTextOptions } from './snippets/autotext.js';
|
|
11
|
+
export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
|
|
12
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';
|
|
13
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
package/dist/index.js
CHANGED
|
@@ -8,4 +8,4 @@ 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
10
|
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
|
|
11
|
-
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, } from './storage.js';
|
|
11
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, } 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
|
/**
|
|
@@ -123,6 +138,12 @@ export declare class MacroKit {
|
|
|
123
138
|
* Imports JSON produced by `exportState`. With `merge: true` an imported
|
|
124
139
|
* item with an existing `id` replaces it; without merge the whole state is
|
|
125
140
|
* replaced.
|
|
141
|
+
*
|
|
142
|
+
* The check is **atomic, on the final result**: a candidate state is built
|
|
143
|
+
* first, its size limits and every shortcut in it are validated (the same
|
|
144
|
+
* rules the save paths enforce — a file cannot smuggle in what typing
|
|
145
|
+
* cannot), and only a candidate that passed in full is committed. On any
|
|
146
|
+
* failure the current state is untouched.
|
|
126
147
|
*/
|
|
127
148
|
importState(json: string, options?: {
|
|
128
149
|
merge?: boolean;
|
|
@@ -130,7 +151,23 @@ export declare class MacroKit {
|
|
|
130
151
|
ok: boolean;
|
|
131
152
|
message?: string;
|
|
132
153
|
};
|
|
154
|
+
/**
|
|
155
|
+
* Every shortcut in a candidate state, under the exact rules of
|
|
156
|
+
* `validateShortcut`: parseable, real modifier, not host-reserved, and
|
|
157
|
+
* unique within the candidate. `importState` is the only caller — the
|
|
158
|
+
* save paths enforce the same rules one item at a time.
|
|
159
|
+
*/
|
|
160
|
+
private validateStateShortcuts;
|
|
133
161
|
private guardRun;
|
|
162
|
+
/**
|
|
163
|
+
* The save-path half of the persistence invariant: field lengths that the
|
|
164
|
+
* loader would reject are refused at the door. Without this, one oversized
|
|
165
|
+
* save would make the whole store unloadable — and the next startup would
|
|
166
|
+
* silently fall back to an empty state, losing everything.
|
|
167
|
+
*/
|
|
168
|
+
private requireItemLimits;
|
|
169
|
+
/** The item-count half of the invariant. `existingId` exempts an in-place update. */
|
|
170
|
+
private requireRoom;
|
|
134
171
|
private upsert;
|
|
135
172
|
private persist;
|
|
136
173
|
}
|
package/dist/manager.js
CHANGED
|
@@ -13,6 +13,7 @@ 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 { IMPORT_LIMITS, isPersistableState } from './storage.js';
|
|
16
17
|
import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
17
18
|
import { MacroError } from './scripting/macro-api.js';
|
|
18
19
|
import { createLocalStorage, emptyState, parsePersistedState } from './storage.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);
|
|
@@ -114,6 +126,8 @@ export class MacroKit {
|
|
|
114
126
|
}
|
|
115
127
|
saveScript(input) {
|
|
116
128
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
129
|
+
this.requireItemLimits({ name: input.name, source: input.source });
|
|
130
|
+
this.requireRoom(this.state.scripts, input.id);
|
|
117
131
|
const script = {
|
|
118
132
|
id: input.id ?? newId(),
|
|
119
133
|
name: input.name,
|
|
@@ -136,6 +150,11 @@ export class MacroKit {
|
|
|
136
150
|
}
|
|
137
151
|
/** Runs an unsaved script — e.g. from the macro editor before saving. */
|
|
138
152
|
async runSource(source) {
|
|
153
|
+
// The real gate: with scripts disabled nothing executes through any
|
|
154
|
+
// path — not a saved script, not an imported one, not its shortcut.
|
|
155
|
+
if (!this.scriptsEnabled) {
|
|
156
|
+
return { ok: false, reason: 'error', message: macroMessages().scriptsDisabled };
|
|
157
|
+
}
|
|
139
158
|
const guard = this.guardRun();
|
|
140
159
|
if (guard)
|
|
141
160
|
return guard;
|
|
@@ -163,7 +182,9 @@ export class MacroKit {
|
|
|
163
182
|
/** Stops and saves. `null` when no step was recorded — there is nothing to save. */
|
|
164
183
|
stopRecording(name, shortcut) {
|
|
165
184
|
this.requireValidShortcut(shortcut);
|
|
166
|
-
|
|
185
|
+
this.requireItemLimits({ name });
|
|
186
|
+
this.requireRoom(this.state.recordings);
|
|
187
|
+
const steps = splitOversizedSteps(this.recorder.stop());
|
|
167
188
|
if (steps.length === 0)
|
|
168
189
|
return null;
|
|
169
190
|
const recording = {
|
|
@@ -195,6 +216,8 @@ export class MacroKit {
|
|
|
195
216
|
return null;
|
|
196
217
|
if (input.shortcut !== undefined)
|
|
197
218
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
219
|
+
if (input.name !== undefined)
|
|
220
|
+
this.requireItemLimits({ name: input.name });
|
|
198
221
|
if (input.name !== undefined)
|
|
199
222
|
recording.name = input.name;
|
|
200
223
|
if (input.shortcut !== undefined) {
|
|
@@ -233,6 +256,8 @@ export class MacroKit {
|
|
|
233
256
|
}
|
|
234
257
|
saveSnippet(input) {
|
|
235
258
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
259
|
+
this.requireItemLimits({ name: input.name, text: input.text, trigger: input.trigger });
|
|
260
|
+
this.requireRoom(this.state.snippets, input.id);
|
|
236
261
|
const snippet = {
|
|
237
262
|
id: input.id ?? newId(),
|
|
238
263
|
name: input.name,
|
|
@@ -274,9 +299,14 @@ export class MacroKit {
|
|
|
274
299
|
}
|
|
275
300
|
currentBindings() {
|
|
276
301
|
const bindings = [];
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
302
|
+
// Part of the scripts gate: with scripts disabled their shortcuts are
|
|
303
|
+
// not bound at all — runScript would refuse anyway, but an unbound key
|
|
304
|
+
// is better than a key that swallows the event just to show an error.
|
|
305
|
+
if (this.scriptsEnabled) {
|
|
306
|
+
for (const script of this.state.scripts) {
|
|
307
|
+
if (script.shortcut)
|
|
308
|
+
bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
|
|
309
|
+
}
|
|
280
310
|
}
|
|
281
311
|
for (const recording of this.state.recordings) {
|
|
282
312
|
if (recording.shortcut)
|
|
@@ -296,25 +326,81 @@ export class MacroKit {
|
|
|
296
326
|
* Imports JSON produced by `exportState`. With `merge: true` an imported
|
|
297
327
|
* item with an existing `id` replaces it; without merge the whole state is
|
|
298
328
|
* replaced.
|
|
329
|
+
*
|
|
330
|
+
* The check is **atomic, on the final result**: a candidate state is built
|
|
331
|
+
* first, its size limits and every shortcut in it are validated (the same
|
|
332
|
+
* rules the save paths enforce — a file cannot smuggle in what typing
|
|
333
|
+
* cannot), and only a candidate that passed in full is committed. On any
|
|
334
|
+
* failure the current state is untouched.
|
|
299
335
|
*/
|
|
300
336
|
importState(json, options = {}) {
|
|
301
337
|
const imported = parsePersistedState(json);
|
|
302
338
|
if (!imported)
|
|
303
339
|
return { ok: false, message: macroMessages().invalidImport };
|
|
340
|
+
let candidate;
|
|
304
341
|
if (options.merge) {
|
|
342
|
+
candidate = {
|
|
343
|
+
version: 1,
|
|
344
|
+
scripts: [...this.state.scripts.map((item) => ({ ...item }))],
|
|
345
|
+
recordings: [...this.state.recordings.map((item) => ({ ...item }))],
|
|
346
|
+
snippets: [...this.state.snippets.map((item) => ({ ...item }))],
|
|
347
|
+
};
|
|
305
348
|
for (const script of imported.scripts)
|
|
306
|
-
this.upsert(
|
|
349
|
+
this.upsert(candidate.scripts, script);
|
|
307
350
|
for (const recording of imported.recordings)
|
|
308
|
-
this.upsert(
|
|
351
|
+
this.upsert(candidate.recordings, recording);
|
|
309
352
|
for (const snippet of imported.snippets)
|
|
310
|
-
this.upsert(
|
|
353
|
+
this.upsert(candidate.snippets, snippet);
|
|
311
354
|
}
|
|
312
355
|
else {
|
|
313
|
-
|
|
356
|
+
candidate = imported;
|
|
314
357
|
}
|
|
358
|
+
// The merged result can exceed what each file alone respected.
|
|
359
|
+
if (!isPersistableState(candidate))
|
|
360
|
+
return { ok: false, message: macroMessages().importTooLarge };
|
|
361
|
+
const shortcutsOk = this.validateStateShortcuts(candidate);
|
|
362
|
+
if (!shortcutsOk.ok)
|
|
363
|
+
return shortcutsOk;
|
|
364
|
+
this.state = candidate;
|
|
315
365
|
this.persist();
|
|
316
366
|
return { ok: true };
|
|
317
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Every shortcut in a candidate state, under the exact rules of
|
|
370
|
+
* `validateShortcut`: parseable, real modifier, not host-reserved, and
|
|
371
|
+
* unique within the candidate. `importState` is the only caller — the
|
|
372
|
+
* save paths enforce the same rules one item at a time.
|
|
373
|
+
*/
|
|
374
|
+
validateStateShortcuts(candidate) {
|
|
375
|
+
const seen = new Map();
|
|
376
|
+
const items = [
|
|
377
|
+
...candidate.scripts,
|
|
378
|
+
...candidate.recordings,
|
|
379
|
+
...candidate.snippets,
|
|
380
|
+
];
|
|
381
|
+
for (const item of items) {
|
|
382
|
+
if (!item.shortcut)
|
|
383
|
+
continue;
|
|
384
|
+
const parsed = parseShortcut(item.shortcut);
|
|
385
|
+
if (!parsed) {
|
|
386
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
|
|
387
|
+
}
|
|
388
|
+
if (!hasBindingModifier(parsed)) {
|
|
389
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutNeedsModifier) };
|
|
390
|
+
}
|
|
391
|
+
for (const signature of shortcutSignatures(parsed)) {
|
|
392
|
+
if (this.reservedSignatures.has(signature)) {
|
|
393
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
|
|
394
|
+
}
|
|
395
|
+
const owner = seen.get(signature);
|
|
396
|
+
if (owner !== undefined) {
|
|
397
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
|
|
398
|
+
}
|
|
399
|
+
seen.set(signature, item.name);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { ok: true };
|
|
403
|
+
}
|
|
318
404
|
/* ---------- Internal ---------- */
|
|
319
405
|
guardRun() {
|
|
320
406
|
if (this.recorder.recording) {
|
|
@@ -325,6 +411,37 @@ export class MacroKit {
|
|
|
325
411
|
}
|
|
326
412
|
return null;
|
|
327
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The save-path half of the persistence invariant: field lengths that the
|
|
416
|
+
* loader would reject are refused at the door. Without this, one oversized
|
|
417
|
+
* save would make the whole store unloadable — and the next startup would
|
|
418
|
+
* silently fall back to an empty state, losing everything.
|
|
419
|
+
*/
|
|
420
|
+
requireItemLimits(fields) {
|
|
421
|
+
const limits = IMPORT_LIMITS;
|
|
422
|
+
if (fields.name.length === 0)
|
|
423
|
+
throw new MacroError(macroMessages().nameRequired, 'invalid-item');
|
|
424
|
+
if (fields.name.length > limits.maxNameLength) {
|
|
425
|
+
throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
|
|
426
|
+
}
|
|
427
|
+
if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
|
|
428
|
+
throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
|
|
429
|
+
}
|
|
430
|
+
if (fields.source !== undefined && fields.source.length > limits.maxSourceLength) {
|
|
431
|
+
throw new MacroError(macroMessages().fieldTooLong('source', limits.maxSourceLength), 'invalid-item');
|
|
432
|
+
}
|
|
433
|
+
if (fields.trigger !== undefined && fields.trigger.length > limits.maxTriggerLength) {
|
|
434
|
+
throw new MacroError(macroMessages().fieldTooLong('trigger', limits.maxTriggerLength), 'invalid-item');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/** The item-count half of the invariant. `existingId` exempts an in-place update. */
|
|
438
|
+
requireRoom(list, existingId) {
|
|
439
|
+
if (existingId && list.some((entry) => entry.id === existingId))
|
|
440
|
+
return;
|
|
441
|
+
if (list.length >= IMPORT_LIMITS.maxItems) {
|
|
442
|
+
throw new MacroError(macroMessages().tooManyItems, 'too-many-items');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
328
445
|
upsert(list, item) {
|
|
329
446
|
const index = list.findIndex((entry) => entry.id === item.id);
|
|
330
447
|
if (index >= 0)
|
|
@@ -333,6 +450,39 @@ export class MacroKit {
|
|
|
333
450
|
list.push(item);
|
|
334
451
|
}
|
|
335
452
|
persist() {
|
|
453
|
+
// Final safety net for the invariant: state the loader would reject is
|
|
454
|
+
// never written. Reaching this branch is a bug in a save path above —
|
|
455
|
+
// the warning is what surfaces it — but the user's stored macros
|
|
456
|
+
// surviving that bug is the point.
|
|
457
|
+
if (!isPersistableState(this.state)) {
|
|
458
|
+
console.warn('[superdoc-macros] refusing to persist state that would fail to load');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
336
461
|
this.storage.save(this.state);
|
|
337
462
|
}
|
|
338
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* A recorded insert-text step can exceed the loader's per-step cap (one huge
|
|
466
|
+
* paste coalesces into one step). Splitting preserves the exact text while
|
|
467
|
+
* keeping the recording loadable.
|
|
468
|
+
*/
|
|
469
|
+
function splitOversizedSteps(steps) {
|
|
470
|
+
const max = IMPORT_LIMITS.maxTextLength;
|
|
471
|
+
const split = steps.flatMap((step) => {
|
|
472
|
+
if (step.type !== 'insert-text' || step.text.length <= max)
|
|
473
|
+
return [step];
|
|
474
|
+
const chunks = [];
|
|
475
|
+
for (let offset = 0; offset < step.text.length; offset += max) {
|
|
476
|
+
chunks.push({ type: 'insert-text', text: step.text.slice(offset, offset + max) });
|
|
477
|
+
}
|
|
478
|
+
return chunks;
|
|
479
|
+
});
|
|
480
|
+
// Splitting can push a cap-length recording past the loader's step limit.
|
|
481
|
+
// Truncating the tail is the honest option left: the alternative is a
|
|
482
|
+
// recording the loader rejects, which loses the whole store's worth more.
|
|
483
|
+
if (split.length > IMPORT_LIMITS.maxStepsPerRecording) {
|
|
484
|
+
console.warn('[superdoc-macros] recording truncated to the step limit');
|
|
485
|
+
return split.slice(0, IMPORT_LIMITS.maxStepsPerRecording);
|
|
486
|
+
}
|
|
487
|
+
return split;
|
|
488
|
+
}
|
package/dist/messages.d.ts
CHANGED
|
@@ -33,7 +33,13 @@ 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;
|
|
37
43
|
shortcutInvalid: string;
|
|
38
44
|
shortcutNeedsModifier: string;
|
|
39
45
|
shortcutReserved: string;
|
package/dist/messages.js
CHANGED
|
@@ -33,7 +33,13 @@ export const ENGLISH_MESSAGES = {
|
|
|
33
33
|
snippetNotFound: 'Snippet not found',
|
|
34
34
|
cannotRunWhileRecording: 'Cannot run a macro while recording',
|
|
35
35
|
anotherMacroRunning: 'Another macro is still running',
|
|
36
|
+
scriptsDisabled: 'Scripted macros are disabled',
|
|
36
37
|
invalidImport: 'The file is not a valid macro export',
|
|
38
|
+
importRejectedShortcut: (itemName, detail) => `Import rejected: the shortcut of "${itemName}" is not acceptable — ${detail}`,
|
|
39
|
+
importTooLarge: 'Import rejected: the merged result exceeds the item limits',
|
|
40
|
+
tooManyItems: 'The list is full — delete items before adding new ones',
|
|
41
|
+
nameRequired: 'A name is required',
|
|
42
|
+
fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
|
|
37
43
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
38
44
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
39
45
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -64,7 +70,13 @@ export const HEBREW_MESSAGES = {
|
|
|
64
70
|
snippetNotFound: 'הקטע לא נמצא',
|
|
65
71
|
cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
|
|
66
72
|
anotherMacroRunning: 'מאקרו אחר עדיין רץ',
|
|
73
|
+
scriptsDisabled: 'מאקרו כתובים מושבתים',
|
|
67
74
|
invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
|
|
75
|
+
importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
|
|
76
|
+
importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
|
|
77
|
+
tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
|
|
78
|
+
nameRequired: 'חובה לתת שם',
|
|
79
|
+
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
68
80
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
69
81
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
70
82
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -18,11 +18,19 @@ export interface RecorderOptions {
|
|
|
18
18
|
shouldRecordCommand?: (id: string) => boolean;
|
|
19
19
|
/** Step cap per recording, against a recording left running by mistake. */
|
|
20
20
|
maxSteps?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Called when the step cap stops the recording. The steps are kept —
|
|
23
|
+
* `stop()` still returns them — but listening has ceased, and a UI that
|
|
24
|
+
* shows "recording" must be told, or its indicator would keep promising a
|
|
25
|
+
* recording that is no longer happening.
|
|
26
|
+
*/
|
|
27
|
+
onAutoStop?: () => void;
|
|
21
28
|
}
|
|
22
29
|
export declare class MacroRecorder {
|
|
23
30
|
private readonly host;
|
|
24
31
|
private readonly shouldRecordCommand;
|
|
25
32
|
private readonly maxSteps;
|
|
33
|
+
private readonly onAutoStop?;
|
|
26
34
|
private steps;
|
|
27
35
|
private disposers;
|
|
28
36
|
private active;
|
|
@@ -30,10 +38,28 @@ export declare class MacroRecorder {
|
|
|
30
38
|
get recording(): boolean;
|
|
31
39
|
get stepCount(): number;
|
|
32
40
|
start(): void;
|
|
33
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Stops and returns the steps. Empty when nothing was recorded. Also the
|
|
43
|
+
* way to collect a recording that auto-stopped at the cap — the steps are
|
|
44
|
+
* kept until someone asks for them.
|
|
45
|
+
*/
|
|
34
46
|
stop(): MacroStep[];
|
|
35
47
|
/** Stops and discards whatever was recorded. */
|
|
36
48
|
cancel(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
51
|
+
* a trigger word plus the expansion character, but what the document now
|
|
52
|
+
* holds is the expanded text — a replay of the raw keystrokes would
|
|
53
|
+
* diverge (and would depend on auto-text being active at replay time).
|
|
54
|
+
* The trailing `consumed` characters are removed from the recorded
|
|
55
|
+
* insert-text steps and the expanded text is recorded in their place.
|
|
56
|
+
*
|
|
57
|
+
* If the tail does not hold `consumed` plain characters (a command landed
|
|
58
|
+
* mid-word, or the recording started mid-trigger), the rewrite is skipped
|
|
59
|
+
* and the raw keystrokes stay — a truthful raw recording beats a guessed
|
|
60
|
+
* edit of steps that do not match.
|
|
61
|
+
*/
|
|
62
|
+
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
37
63
|
private teardown;
|
|
38
64
|
private push;
|
|
39
65
|
private recordCommand;
|
|
@@ -7,6 +7,7 @@ export class MacroRecorder {
|
|
|
7
7
|
host;
|
|
8
8
|
shouldRecordCommand;
|
|
9
9
|
maxSteps;
|
|
10
|
+
onAutoStop;
|
|
10
11
|
steps = [];
|
|
11
12
|
disposers = [];
|
|
12
13
|
active = false;
|
|
@@ -14,6 +15,7 @@ export class MacroRecorder {
|
|
|
14
15
|
this.host = host;
|
|
15
16
|
this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
|
|
16
17
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
18
|
+
this.onAutoStop = options.onAutoStop;
|
|
17
19
|
}
|
|
18
20
|
get recording() {
|
|
19
21
|
return this.active;
|
|
@@ -31,10 +33,12 @@ export class MacroRecorder {
|
|
|
31
33
|
this.host.onTextInput((event) => this.recordTextInput(event)),
|
|
32
34
|
];
|
|
33
35
|
}
|
|
34
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Stops and returns the steps. Empty when nothing was recorded. Also the
|
|
38
|
+
* way to collect a recording that auto-stopped at the cap — the steps are
|
|
39
|
+
* kept until someone asks for them.
|
|
40
|
+
*/
|
|
35
41
|
stop() {
|
|
36
|
-
if (!this.active)
|
|
37
|
-
return [];
|
|
38
42
|
this.teardown();
|
|
39
43
|
const recorded = this.steps;
|
|
40
44
|
this.steps = [];
|
|
@@ -42,22 +46,64 @@ export class MacroRecorder {
|
|
|
42
46
|
}
|
|
43
47
|
/** Stops and discards whatever was recorded. */
|
|
44
48
|
cancel() {
|
|
45
|
-
if (!this.active)
|
|
46
|
-
return;
|
|
47
49
|
this.teardown();
|
|
48
50
|
this.steps = [];
|
|
49
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
54
|
+
* a trigger word plus the expansion character, but what the document now
|
|
55
|
+
* holds is the expanded text — a replay of the raw keystrokes would
|
|
56
|
+
* diverge (and would depend on auto-text being active at replay time).
|
|
57
|
+
* The trailing `consumed` characters are removed from the recorded
|
|
58
|
+
* insert-text steps and the expanded text is recorded in their place.
|
|
59
|
+
*
|
|
60
|
+
* If the tail does not hold `consumed` plain characters (a command landed
|
|
61
|
+
* mid-word, or the recording started mid-trigger), the rewrite is skipped
|
|
62
|
+
* and the raw keystrokes stay — a truthful raw recording beats a guessed
|
|
63
|
+
* edit of steps that do not match.
|
|
64
|
+
*/
|
|
65
|
+
applyAutoTextExpansion(consumed, replacement) {
|
|
66
|
+
if (!this.active || consumed <= 0)
|
|
67
|
+
return;
|
|
68
|
+
// Verify the tail is entirely typed text before touching anything.
|
|
69
|
+
let remaining = consumed;
|
|
70
|
+
let index = this.steps.length - 1;
|
|
71
|
+
while (remaining > 0 && index >= 0) {
|
|
72
|
+
const step = this.steps[index];
|
|
73
|
+
if (step?.type !== 'insert-text')
|
|
74
|
+
return;
|
|
75
|
+
remaining -= step.text.length;
|
|
76
|
+
index -= 1;
|
|
77
|
+
}
|
|
78
|
+
if (remaining > 0)
|
|
79
|
+
return;
|
|
80
|
+
let toRemove = consumed;
|
|
81
|
+
while (toRemove > 0) {
|
|
82
|
+
const last = this.steps[this.steps.length - 1];
|
|
83
|
+
if (last?.type !== 'insert-text')
|
|
84
|
+
return; // unreachable after the check above
|
|
85
|
+
if (last.text.length > toRemove) {
|
|
86
|
+
last.text = last.text.slice(0, -toRemove);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
toRemove -= last.text.length;
|
|
90
|
+
this.steps.pop();
|
|
91
|
+
}
|
|
92
|
+
this.recordTextInput({ kind: 'insert-text', text: replacement });
|
|
93
|
+
}
|
|
50
94
|
teardown() {
|
|
51
95
|
this.active = false;
|
|
52
96
|
for (const dispose of this.disposers.splice(0))
|
|
53
97
|
dispose();
|
|
54
98
|
}
|
|
55
99
|
push(step) {
|
|
100
|
+
this.steps.push(step);
|
|
56
101
|
if (this.steps.length >= this.maxSteps) {
|
|
102
|
+
// The cap stops the *listening*, not the data: the steps stay for
|
|
103
|
+
// stop() to collect, and the owner is told the recording ended.
|
|
57
104
|
this.teardown();
|
|
58
|
-
|
|
105
|
+
this.onAutoStop?.();
|
|
59
106
|
}
|
|
60
|
-
this.steps.push(step);
|
|
61
107
|
}
|
|
62
108
|
recordCommand(id, payload) {
|
|
63
109
|
if (!this.shouldRecordCommand(id))
|
|
@@ -15,13 +15,22 @@
|
|
|
15
15
|
* better than an expansion that deletes the wrong text.
|
|
16
16
|
*/
|
|
17
17
|
import type { MacroHost, Snippet } from '../types.js';
|
|
18
|
+
/** What an expansion actually did — what a recorder needs to stay truthful. */
|
|
19
|
+
export interface AutoTextExpansion {
|
|
20
|
+
/** The trigger word the user typed. */
|
|
21
|
+
trigger: string;
|
|
22
|
+
/** The character that fired the expansion (and was restored at the end). */
|
|
23
|
+
expandChar: string;
|
|
24
|
+
/** The rendered snippet text that replaced the trigger. */
|
|
25
|
+
rendered: string;
|
|
26
|
+
}
|
|
18
27
|
export interface AutoTextOptions {
|
|
19
28
|
/** The expansion characters. Default: space only. */
|
|
20
29
|
expandOn?: readonly string[];
|
|
21
30
|
/** Buffer size. A trigger word longer than this will not be recognized. */
|
|
22
31
|
bufferSize?: number;
|
|
23
32
|
/** Called after a successful expansion. */
|
|
24
|
-
onExpand?: (snippet: Snippet) => void;
|
|
33
|
+
onExpand?: (snippet: Snippet, expansion: AutoTextExpansion) => void;
|
|
25
34
|
/** Called when an expansion failed (e.g. a read-only document). */
|
|
26
35
|
onError?: (message: string) => void;
|
|
27
36
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -34,6 +34,13 @@ export declare const IMPORT_LIMITS: {
|
|
|
34
34
|
readonly maxTextLength: 100000;
|
|
35
35
|
readonly maxSourceLength: 200000;
|
|
36
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* Whether a state object passes the exact validation the loader applies.
|
|
39
|
+
* The save paths hold this as an invariant: state that would be rejected on
|
|
40
|
+
* the next load must never be persisted — otherwise a single oversized save
|
|
41
|
+
* silently wipes everything at the next startup.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isPersistableState(value: unknown): value is PersistedMacroState;
|
|
37
44
|
/**
|
|
38
45
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
39
46
|
* field or oversized file — never throws, never partially accepts: one
|
package/dist/storage.js
CHANGED
|
@@ -91,6 +91,15 @@ function isValidState(value) {
|
|
|
91
91
|
state.snippets.length <= IMPORT_LIMITS.maxItems &&
|
|
92
92
|
state.snippets.every(isValidSnippet));
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether a state object passes the exact validation the loader applies.
|
|
96
|
+
* The save paths hold this as an invariant: state that would be rejected on
|
|
97
|
+
* the next load must never be persisted — otherwise a single oversized save
|
|
98
|
+
* silently wipes everything at the next startup.
|
|
99
|
+
*/
|
|
100
|
+
export function isPersistableState(value) {
|
|
101
|
+
return isValidState(value);
|
|
102
|
+
}
|
|
94
103
|
/**
|
|
95
104
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
96
105
|
* field or oversized file — never throws, never partially accepts: one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdoc-macros",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|