superdoc-macros 0.3.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 +17 -1
- package/dist/host/superdoc-host.d.ts +8 -0
- package/dist/host/superdoc-host.js +116 -27
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/manager.d.ts +62 -0
- package/dist/manager.js +226 -11
- package/dist/messages.d.ts +12 -1
- package/dist/messages.js +24 -2
- package/dist/recorder/recorder.d.ts +41 -1
- package/dist/recorder/recorder.js +54 -24
- package/dist/scripting/eval-runner.d.ts +13 -0
- package/dist/scripting/eval-runner.js +31 -2
- package/dist/scripting/iframe-runner.js +15 -4
- package/dist/shortcuts.d.ts +12 -0
- package/dist/shortcuts.js +19 -0
- package/dist/snippets/autotext.d.ts +10 -1
- package/dist/snippets/autotext.js +1 -1
- package/dist/storage.d.ts +31 -1
- package/dist/storage.js +99 -2
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/manager.js
CHANGED
|
@@ -13,7 +13,9 @@ 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 {
|
|
16
|
+
import { IMPORT_LIMITS, isPersistableState } from './storage.js';
|
|
17
|
+
import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
18
|
+
import { MacroError } from './scripting/macro-api.js';
|
|
17
19
|
import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
|
|
18
20
|
import { macroMessages } from './messages.js';
|
|
19
21
|
let idCounter = 0;
|
|
@@ -35,6 +37,8 @@ export class MacroKit {
|
|
|
35
37
|
state;
|
|
36
38
|
recorder;
|
|
37
39
|
autoText;
|
|
40
|
+
reservedSignatures;
|
|
41
|
+
scriptsEnabled;
|
|
38
42
|
running = false;
|
|
39
43
|
constructor(options) {
|
|
40
44
|
this.host = options.host;
|
|
@@ -44,15 +48,86 @@ export class MacroKit {
|
|
|
44
48
|
const runner = options.runner ?? 'iframe';
|
|
45
49
|
this.runner =
|
|
46
50
|
runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
|
|
51
|
+
this.scriptsEnabled = options.scriptsEnabled ?? true;
|
|
47
52
|
this.state = this.storage.load() ?? emptyState();
|
|
48
|
-
this.recorder = new MacroRecorder(this.host);
|
|
49
|
-
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
|
+
});
|
|
64
|
+
const reserved = new Set();
|
|
65
|
+
for (const shortcut of options.reservedShortcuts ?? []) {
|
|
66
|
+
const parsed = parseShortcut(shortcut);
|
|
67
|
+
if (parsed)
|
|
68
|
+
for (const signature of shortcutSignatures(parsed))
|
|
69
|
+
reserved.add(signature);
|
|
70
|
+
}
|
|
71
|
+
this.reservedSignatures = reserved;
|
|
72
|
+
}
|
|
73
|
+
/* ---------- Shortcut validation ---------- */
|
|
74
|
+
/**
|
|
75
|
+
* Whether a shortcut is acceptable for a saved binding: parseable, carries
|
|
76
|
+
* a real modifier, not reserved by the host, and not already used by
|
|
77
|
+
* another saved item (`excludeId` skips the item being edited). Empty or
|
|
78
|
+
* undefined means "no shortcut" and is fine. The save paths enforce this;
|
|
79
|
+
* UIs call it directly to show the message before saving.
|
|
80
|
+
*/
|
|
81
|
+
validateShortcut(shortcut, excludeId) {
|
|
82
|
+
const trimmed = shortcut?.trim();
|
|
83
|
+
if (!trimmed)
|
|
84
|
+
return { ok: true };
|
|
85
|
+
const parsed = parseShortcut(trimmed);
|
|
86
|
+
if (!parsed)
|
|
87
|
+
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
88
|
+
if (!hasBindingModifier(parsed))
|
|
89
|
+
return { ok: false, message: macroMessages().shortcutNeedsModifier };
|
|
90
|
+
const signatures = shortcutSignatures(parsed);
|
|
91
|
+
if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
|
|
92
|
+
return { ok: false, message: macroMessages().shortcutReserved };
|
|
93
|
+
}
|
|
94
|
+
const owner = this.findShortcutOwner(signatures, excludeId);
|
|
95
|
+
if (owner)
|
|
96
|
+
return { ok: false, message: macroMessages().shortcutTaken(owner) };
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
findShortcutOwner(signatures, excludeId) {
|
|
100
|
+
const items = [
|
|
101
|
+
...this.state.scripts,
|
|
102
|
+
...this.state.recordings,
|
|
103
|
+
...this.state.snippets,
|
|
104
|
+
];
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
if (!item.shortcut || item.id === excludeId)
|
|
107
|
+
continue;
|
|
108
|
+
const parsed = parseShortcut(item.shortcut);
|
|
109
|
+
if (!parsed)
|
|
110
|
+
continue;
|
|
111
|
+
const existing = shortcutSignatures(parsed);
|
|
112
|
+
if (existing.some((signature) => signatures.includes(signature)))
|
|
113
|
+
return item.name;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
|
|
118
|
+
requireValidShortcut(shortcut, excludeId) {
|
|
119
|
+
const validation = this.validateShortcut(shortcut, excludeId);
|
|
120
|
+
if (!validation.ok)
|
|
121
|
+
throw new MacroError(validation.message, 'invalid-shortcut');
|
|
50
122
|
}
|
|
51
123
|
/* ---------- Scripts ---------- */
|
|
52
124
|
listScripts() {
|
|
53
125
|
return this.state.scripts;
|
|
54
126
|
}
|
|
55
127
|
saveScript(input) {
|
|
128
|
+
this.requireValidShortcut(input.shortcut, input.id);
|
|
129
|
+
this.requireItemLimits({ name: input.name, source: input.source });
|
|
130
|
+
this.requireRoom(this.state.scripts, input.id);
|
|
56
131
|
const script = {
|
|
57
132
|
id: input.id ?? newId(),
|
|
58
133
|
name: input.name,
|
|
@@ -75,6 +150,11 @@ export class MacroKit {
|
|
|
75
150
|
}
|
|
76
151
|
/** Runs an unsaved script — e.g. from the macro editor before saving. */
|
|
77
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
|
+
}
|
|
78
158
|
const guard = this.guardRun();
|
|
79
159
|
if (guard)
|
|
80
160
|
return guard;
|
|
@@ -101,7 +181,10 @@ export class MacroKit {
|
|
|
101
181
|
}
|
|
102
182
|
/** Stops and saves. `null` when no step was recorded — there is nothing to save. */
|
|
103
183
|
stopRecording(name, shortcut) {
|
|
104
|
-
|
|
184
|
+
this.requireValidShortcut(shortcut);
|
|
185
|
+
this.requireItemLimits({ name });
|
|
186
|
+
this.requireRoom(this.state.recordings);
|
|
187
|
+
const steps = splitOversizedSteps(this.recorder.stop());
|
|
105
188
|
if (steps.length === 0)
|
|
106
189
|
return null;
|
|
107
190
|
const recording = {
|
|
@@ -131,6 +214,10 @@ export class MacroKit {
|
|
|
131
214
|
const recording = this.state.recordings.find((entry) => entry.id === input.id);
|
|
132
215
|
if (!recording)
|
|
133
216
|
return null;
|
|
217
|
+
if (input.shortcut !== undefined)
|
|
218
|
+
this.requireValidShortcut(input.shortcut, input.id);
|
|
219
|
+
if (input.name !== undefined)
|
|
220
|
+
this.requireItemLimits({ name: input.name });
|
|
134
221
|
if (input.name !== undefined)
|
|
135
222
|
recording.name = input.name;
|
|
136
223
|
if (input.shortcut !== undefined) {
|
|
@@ -168,6 +255,9 @@ export class MacroKit {
|
|
|
168
255
|
return this.state.snippets;
|
|
169
256
|
}
|
|
170
257
|
saveSnippet(input) {
|
|
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);
|
|
171
261
|
const snippet = {
|
|
172
262
|
id: input.id ?? newId(),
|
|
173
263
|
name: input.name,
|
|
@@ -209,9 +299,14 @@ export class MacroKit {
|
|
|
209
299
|
}
|
|
210
300
|
currentBindings() {
|
|
211
301
|
const bindings = [];
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
|
215
310
|
}
|
|
216
311
|
for (const recording of this.state.recordings) {
|
|
217
312
|
if (recording.shortcut)
|
|
@@ -231,25 +326,81 @@ export class MacroKit {
|
|
|
231
326
|
* Imports JSON produced by `exportState`. With `merge: true` an imported
|
|
232
327
|
* item with an existing `id` replaces it; without merge the whole state is
|
|
233
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.
|
|
234
335
|
*/
|
|
235
336
|
importState(json, options = {}) {
|
|
236
337
|
const imported = parsePersistedState(json);
|
|
237
338
|
if (!imported)
|
|
238
339
|
return { ok: false, message: macroMessages().invalidImport };
|
|
340
|
+
let candidate;
|
|
239
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
|
+
};
|
|
240
348
|
for (const script of imported.scripts)
|
|
241
|
-
this.upsert(
|
|
349
|
+
this.upsert(candidate.scripts, script);
|
|
242
350
|
for (const recording of imported.recordings)
|
|
243
|
-
this.upsert(
|
|
351
|
+
this.upsert(candidate.recordings, recording);
|
|
244
352
|
for (const snippet of imported.snippets)
|
|
245
|
-
this.upsert(
|
|
353
|
+
this.upsert(candidate.snippets, snippet);
|
|
246
354
|
}
|
|
247
355
|
else {
|
|
248
|
-
|
|
356
|
+
candidate = imported;
|
|
249
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;
|
|
250
365
|
this.persist();
|
|
251
366
|
return { ok: true };
|
|
252
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
|
+
}
|
|
253
404
|
/* ---------- Internal ---------- */
|
|
254
405
|
guardRun() {
|
|
255
406
|
if (this.recorder.recording) {
|
|
@@ -260,6 +411,37 @@ export class MacroKit {
|
|
|
260
411
|
}
|
|
261
412
|
return null;
|
|
262
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
|
+
}
|
|
263
445
|
upsert(list, item) {
|
|
264
446
|
const index = list.findIndex((entry) => entry.id === item.id);
|
|
265
447
|
if (index >= 0)
|
|
@@ -268,6 +450,39 @@ export class MacroKit {
|
|
|
268
450
|
list.push(item);
|
|
269
451
|
}
|
|
270
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
|
+
}
|
|
271
461
|
this.storage.save(this.state);
|
|
272
462
|
}
|
|
273
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
|
@@ -27,14 +27,25 @@ export interface MacroMessages {
|
|
|
27
27
|
syntaxError: (detail: string) => string;
|
|
28
28
|
timedOut: (seconds: number) => string;
|
|
29
29
|
callLimitExceeded: (limit: number) => string;
|
|
30
|
-
|
|
30
|
+
macroStopped: string;
|
|
31
31
|
scriptNotFound: string;
|
|
32
32
|
recordingNotFound: string;
|
|
33
33
|
snippetNotFound: string;
|
|
34
34
|
cannotRunWhileRecording: string;
|
|
35
35
|
anotherMacroRunning: string;
|
|
36
|
+
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
|
+
shortcutInvalid: string;
|
|
44
|
+
shortcutNeedsModifier: string;
|
|
45
|
+
shortcutReserved: string;
|
|
46
|
+
shortcutTaken: (ownerName: string) => string;
|
|
37
47
|
noDocument: string;
|
|
48
|
+
selectionUnavailable: string;
|
|
38
49
|
unknownCommand: (id: string) => string;
|
|
39
50
|
actionFailed: string;
|
|
40
51
|
deletionUnavailable: string;
|
package/dist/messages.js
CHANGED
|
@@ -27,14 +27,25 @@ export const ENGLISH_MESSAGES = {
|
|
|
27
27
|
syntaxError: (detail) => `Macro syntax error: ${detail}`,
|
|
28
28
|
timedOut: (seconds) => `The macro did not finish within ${seconds} seconds and was stopped`,
|
|
29
29
|
callLimitExceeded: (limit) => `The macro exceeded the API call limit (${limit}) and was stopped`,
|
|
30
|
-
|
|
30
|
+
macroStopped: 'The macro was stopped — the call was not executed',
|
|
31
31
|
scriptNotFound: 'Macro not found',
|
|
32
32
|
recordingNotFound: 'Recording not found',
|
|
33
33
|
snippetNotFound: 'Snippet not found',
|
|
34
34
|
cannotRunWhileRecording: 'Cannot run a macro while recording',
|
|
35
35
|
anotherMacroRunning: 'Another macro is still running',
|
|
36
|
+
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)`,
|
|
43
|
+
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
44
|
+
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
45
|
+
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
46
|
+
shortcutTaken: (ownerName) => `This shortcut is already used by "${ownerName}"`,
|
|
37
47
|
noDocument: 'No document is open',
|
|
48
|
+
selectionUnavailable: 'The caret position could not be read — nothing was inserted',
|
|
38
49
|
unknownCommand: (id) => `The engine does not recognize the command ${id}`,
|
|
39
50
|
actionFailed: 'The operation failed',
|
|
40
51
|
deletionUnavailable: 'Deletion is not available in this document',
|
|
@@ -53,14 +64,25 @@ export const HEBREW_MESSAGES = {
|
|
|
53
64
|
syntaxError: (detail) => `שגיאת תחביר במאקרו: ${detail}`,
|
|
54
65
|
timedOut: (seconds) => `המאקרו לא הסתיים תוך ${seconds} שניות ונעצר`,
|
|
55
66
|
callLimitExceeded: (limit) => `המאקרו חצה את תקרת הקריאות (${limit}) ונעצר`,
|
|
56
|
-
|
|
67
|
+
macroStopped: 'המאקרו נעצר — הקריאה לא בוצעה',
|
|
57
68
|
scriptNotFound: 'המאקרו לא נמצא',
|
|
58
69
|
recordingNotFound: 'ההקלטה לא נמצאה',
|
|
59
70
|
snippetNotFound: 'הקטע לא נמצא',
|
|
60
71
|
cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
|
|
61
72
|
anotherMacroRunning: 'מאקרו אחר עדיין רץ',
|
|
73
|
+
scriptsDisabled: 'מאקרו כתובים מושבתים',
|
|
62
74
|
invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
|
|
75
|
+
importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
|
|
76
|
+
importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
|
|
77
|
+
tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
|
|
78
|
+
nameRequired: 'חובה לתת שם',
|
|
79
|
+
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
80
|
+
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
81
|
+
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
82
|
+
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
83
|
+
shortcutTaken: (ownerName) => `הקיצור כבר בשימוש של "${ownerName}"`,
|
|
63
84
|
noDocument: 'אין מסמך פתוח',
|
|
85
|
+
selectionUnavailable: 'קריאת מיקום הסמן נכשלה — לא הוכנס דבר',
|
|
64
86
|
unknownCommand: (id) => `הפקודה ${id} אינה מוכרת למנוע`,
|
|
65
87
|
actionFailed: 'הפעולה נכשלה',
|
|
66
88
|
deletionUnavailable: 'מחיקה אינה זמינה במסמך הזה',
|
|
@@ -1,14 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
+
* positions.
|
|
4
|
+
*
|
|
5
|
+
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
+
* positions) breaks the moment the document differs from what it was at
|
|
7
|
+
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
+
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
+
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
+
* shareable: the steps are plain JSON.
|
|
11
|
+
*
|
|
12
|
+
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
+
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
+
*/
|
|
1
15
|
import type { MacroHost, MacroStep } from '../types.js';
|
|
2
16
|
export interface RecorderOptions {
|
|
3
17
|
/** Command filter. The default records everything except undo/redo. */
|
|
4
18
|
shouldRecordCommand?: (id: string) => boolean;
|
|
5
19
|
/** Step cap per recording, against a recording left running by mistake. */
|
|
6
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;
|
|
7
28
|
}
|
|
8
29
|
export declare class MacroRecorder {
|
|
9
30
|
private readonly host;
|
|
10
31
|
private readonly shouldRecordCommand;
|
|
11
32
|
private readonly maxSteps;
|
|
33
|
+
private readonly onAutoStop?;
|
|
12
34
|
private steps;
|
|
13
35
|
private disposers;
|
|
14
36
|
private active;
|
|
@@ -16,10 +38,28 @@ export declare class MacroRecorder {
|
|
|
16
38
|
get recording(): boolean;
|
|
17
39
|
get stepCount(): number;
|
|
18
40
|
start(): void;
|
|
19
|
-
/**
|
|
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
|
+
*/
|
|
20
46
|
stop(): MacroStep[];
|
|
21
47
|
/** Stops and discards whatever was recorded. */
|
|
22
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;
|
|
23
63
|
private teardown;
|
|
24
64
|
private push;
|
|
25
65
|
private recordCommand;
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
-
* positions.
|
|
4
|
-
*
|
|
5
|
-
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
-
* positions) breaks the moment the document differs from what it was at
|
|
7
|
-
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
-
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
-
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
-
* shareable: the steps are plain JSON.
|
|
11
|
-
*
|
|
12
|
-
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
-
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
-
*/
|
|
15
|
-
import { macroMessages } from '../messages.js';
|
|
16
1
|
const DEFAULT_MAX_STEPS = 5_000;
|
|
17
2
|
/** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
|
|
18
3
|
function defaultShouldRecord(id) {
|
|
@@ -22,6 +7,7 @@ export class MacroRecorder {
|
|
|
22
7
|
host;
|
|
23
8
|
shouldRecordCommand;
|
|
24
9
|
maxSteps;
|
|
10
|
+
onAutoStop;
|
|
25
11
|
steps = [];
|
|
26
12
|
disposers = [];
|
|
27
13
|
active = false;
|
|
@@ -29,6 +15,7 @@ export class MacroRecorder {
|
|
|
29
15
|
this.host = host;
|
|
30
16
|
this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
|
|
31
17
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
18
|
+
this.onAutoStop = options.onAutoStop;
|
|
32
19
|
}
|
|
33
20
|
get recording() {
|
|
34
21
|
return this.active;
|
|
@@ -46,10 +33,12 @@ export class MacroRecorder {
|
|
|
46
33
|
this.host.onTextInput((event) => this.recordTextInput(event)),
|
|
47
34
|
];
|
|
48
35
|
}
|
|
49
|
-
/**
|
|
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
|
+
*/
|
|
50
41
|
stop() {
|
|
51
|
-
if (!this.active)
|
|
52
|
-
return [];
|
|
53
42
|
this.teardown();
|
|
54
43
|
const recorded = this.steps;
|
|
55
44
|
this.steps = [];
|
|
@@ -57,22 +46,64 @@ export class MacroRecorder {
|
|
|
57
46
|
}
|
|
58
47
|
/** Stops and discards whatever was recorded. */
|
|
59
48
|
cancel() {
|
|
60
|
-
if (!this.active)
|
|
61
|
-
return;
|
|
62
49
|
this.teardown();
|
|
63
50
|
this.steps = [];
|
|
64
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
|
+
}
|
|
65
94
|
teardown() {
|
|
66
95
|
this.active = false;
|
|
67
96
|
for (const dispose of this.disposers.splice(0))
|
|
68
97
|
dispose();
|
|
69
98
|
}
|
|
70
99
|
push(step) {
|
|
100
|
+
this.steps.push(step);
|
|
71
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.
|
|
72
104
|
this.teardown();
|
|
73
|
-
|
|
105
|
+
this.onAutoStop?.();
|
|
74
106
|
}
|
|
75
|
-
this.steps.push(step);
|
|
76
107
|
}
|
|
77
108
|
recordCommand(id, payload) {
|
|
78
109
|
if (!this.shouldRecordCommand(id))
|
|
@@ -124,8 +155,7 @@ async function runStep(host, step) {
|
|
|
124
155
|
case 'delete-backward':
|
|
125
156
|
return host.deleteBackward(step.count);
|
|
126
157
|
case 'delete-forward':
|
|
127
|
-
|
|
128
|
-
return { ok: false, message: macroMessages().deleteForwardUnsupported, reason: 'unsupported-step' };
|
|
158
|
+
return host.deleteForward(step.count);
|
|
129
159
|
}
|
|
130
160
|
}
|
|
131
161
|
export async function replayMacro(host, steps, options = {}) {
|
|
@@ -2,4 +2,17 @@ import type { MacroBridge } from './macro-api.js';
|
|
|
2
2
|
import { type MacroRunner } from './runner.js';
|
|
3
3
|
/** Wraps a bridge with a call cap. Exposed so both runners share the same enforcement. */
|
|
4
4
|
export declare function limitCalls(bridge: MacroBridge, maxCalls: number): MacroBridge;
|
|
5
|
+
/**
|
|
6
|
+
* Wraps a bridge with a kill switch. After `revoke()` every new call is
|
|
7
|
+
* rejected — so a script that keeps running past its timeout (the eval
|
|
8
|
+
* runner cannot stop it) can no longer touch the document.
|
|
9
|
+
*
|
|
10
|
+
* What this cannot do: abort a host call that already reached the engine.
|
|
11
|
+
* The engine's public surfaces expose no cancellation, so an in-flight
|
|
12
|
+
* operation completes; what is guaranteed is that nothing *new* starts.
|
|
13
|
+
*/
|
|
14
|
+
export declare function revocable(bridge: MacroBridge): {
|
|
15
|
+
bridge: MacroBridge;
|
|
16
|
+
revoke: () => void;
|
|
17
|
+
};
|
|
5
18
|
export declare function createEvalRunner(): MacroRunner;
|