superdoc-macros 0.7.0 → 0.8.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 +97 -3
- package/dist/import/cfb.d.ts +44 -0
- package/dist/import/cfb.js +378 -0
- package/dist/import/errors.d.ts +21 -0
- package/dist/import/errors.js +20 -0
- package/dist/import/ms-ovba.d.ts +20 -0
- package/dist/import/ms-ovba.js +201 -0
- package/dist/import/vba.d.ts +132 -0
- package/dist/import/vba.js +785 -0
- package/dist/import/zip.d.ts +35 -0
- package/dist/import/zip.js +253 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +7 -1
- package/dist/manager.js +21 -6
- package/dist/messages.d.ts +6 -0
- package/dist/messages.js +2 -0
- package/dist/recorder/recorder.d.ts +7 -0
- package/dist/recorder/recorder.js +24 -9
- package/package.json +4 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const ZIP_LIMITS: {
|
|
2
|
+
readonly maxEntries: 5000;
|
|
3
|
+
/** Uncompressed size of any single part this reader will inflate. */
|
|
4
|
+
readonly maxEntryBytes: number;
|
|
5
|
+
readonly maxNameLength: 1024;
|
|
6
|
+
};
|
|
7
|
+
export interface ZipEntry {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly compressedSize: number;
|
|
10
|
+
readonly uncompressedSize: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ZipReadOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Hard ceiling on the bytes this read may produce, defaulting to
|
|
15
|
+
* `ZIP_LIMITS.maxEntryBytes`. Enforced *during* decompression, so a part
|
|
16
|
+
* that inflates past it is abandoned rather than buffered and then
|
|
17
|
+
* rejected. Callers who know a part should be small (a `.rels` file) should
|
|
18
|
+
* say so — that is the difference between refusing a bomb and hosting it.
|
|
19
|
+
*/
|
|
20
|
+
maxBytes?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ZipArchive {
|
|
23
|
+
readonly entries: readonly ZipEntry[];
|
|
24
|
+
has(name: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The inflated bytes of one entry.
|
|
27
|
+
*
|
|
28
|
+
* @throws {VbaParseError} when the entry is absent, encrypted, compressed
|
|
29
|
+
* with an unsupported method, larger than the cap, or its data runs past
|
|
30
|
+
* the end of the file.
|
|
31
|
+
*/
|
|
32
|
+
read(name: string, options?: ZipReadOptions): Promise<Uint8Array>;
|
|
33
|
+
}
|
|
34
|
+
/** Parses the central directory. Reading an entry's data stays lazy. */
|
|
35
|
+
export declare function openZip(bytes: Uint8Array): ZipArchive;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A read-only ZIP reader, enough to pull named parts out of an OOXML package.
|
|
3
|
+
*
|
|
4
|
+
* A `.docm` is a ZIP whose entries are the document's parts. To reach the
|
|
5
|
+
* macro project we need two or three specific parts by name, never the whole
|
|
6
|
+
* archive — so this reader parses the central directory up front (cheap) and
|
|
7
|
+
* inflates only what a caller asks for.
|
|
8
|
+
*
|
|
9
|
+
* Inflation uses the platform's own `DecompressionStream` — Baseline across
|
|
10
|
+
* browsers since 2023, and present in Node 18+. That keeps the toolkit
|
|
11
|
+
* dependency-free and puts the decompression itself in audited native code
|
|
12
|
+
* rather than in a hand-rolled inflater. Where it is genuinely absent, the
|
|
13
|
+
* reader says so rather than failing obscurely. Nothing else is assumed of the
|
|
14
|
+
* platform: no `Blob`, no `fetch`, no `Response` — only streams, so this also
|
|
15
|
+
* runs under jsdom, where a host's tests live.
|
|
16
|
+
*
|
|
17
|
+
* Safety posture, because these bytes come from a file someone was sent:
|
|
18
|
+
*
|
|
19
|
+
* - Entry count and name length are capped, and the per-part size cap is
|
|
20
|
+
* enforced against the bytes decompression actually produces — not against
|
|
21
|
+
* the size the archive claims, which a decompression bomb simply lies about.
|
|
22
|
+
* - Encrypted and ZIP64 entries are refused explicitly rather than guessed at.
|
|
23
|
+
* - Nothing here writes, and no entry name is ever used as a filesystem path,
|
|
24
|
+
* so the classic zip-slip traversal has no surface to attack.
|
|
25
|
+
*/
|
|
26
|
+
import { VbaParseError } from './errors.js';
|
|
27
|
+
export const ZIP_LIMITS = {
|
|
28
|
+
maxEntries: 5_000,
|
|
29
|
+
/** Uncompressed size of any single part this reader will inflate. */
|
|
30
|
+
maxEntryBytes: 64 * 1024 * 1024,
|
|
31
|
+
maxNameLength: 1_024,
|
|
32
|
+
};
|
|
33
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
34
|
+
const CENTRAL_SIGNATURE = 0x02014b50;
|
|
35
|
+
const LOCAL_SIGNATURE = 0x04034b50;
|
|
36
|
+
const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
|
|
37
|
+
const EOCD_MIN_BYTES = 22;
|
|
38
|
+
/** The trailing comment a ZIP may carry is at most 64 KiB. */
|
|
39
|
+
const MAX_COMMENT_BYTES = 0xffff;
|
|
40
|
+
const ZIP64_MARKER_32 = 0xffffffff;
|
|
41
|
+
const ZIP64_MARKER_16 = 0xffff;
|
|
42
|
+
const METHOD_STORED = 0;
|
|
43
|
+
const METHOD_DEFLATE = 8;
|
|
44
|
+
/** Parses the central directory. Reading an entry's data stays lazy. */
|
|
45
|
+
export function openZip(bytes) {
|
|
46
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
47
|
+
const u16 = (offset) => {
|
|
48
|
+
if (offset < 0 || offset + 2 > bytes.length) {
|
|
49
|
+
throw new VbaParseError('malformed', 'package: read past the end of the file');
|
|
50
|
+
}
|
|
51
|
+
return view.getUint16(offset, true);
|
|
52
|
+
};
|
|
53
|
+
const u32 = (offset) => {
|
|
54
|
+
if (offset < 0 || offset + 4 > bytes.length) {
|
|
55
|
+
throw new VbaParseError('malformed', 'package: read past the end of the file');
|
|
56
|
+
}
|
|
57
|
+
return view.getUint32(offset, true);
|
|
58
|
+
};
|
|
59
|
+
if (bytes.length < EOCD_MIN_BYTES) {
|
|
60
|
+
throw new VbaParseError('malformed', 'package: file is too short to be a ZIP archive');
|
|
61
|
+
}
|
|
62
|
+
// The end-of-central-directory record sits at the very end, behind an
|
|
63
|
+
// optional comment — so it has to be found by scanning backwards.
|
|
64
|
+
const scanFloor = Math.max(0, bytes.length - EOCD_MIN_BYTES - MAX_COMMENT_BYTES);
|
|
65
|
+
let eocd = -1;
|
|
66
|
+
for (let at = bytes.length - EOCD_MIN_BYTES; at >= scanFloor; at -= 1) {
|
|
67
|
+
if (view.getUint32(at, true) === EOCD_SIGNATURE) {
|
|
68
|
+
eocd = at;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (eocd < 0) {
|
|
73
|
+
throw new VbaParseError('malformed', 'package: not a ZIP archive (no end-of-directory record)');
|
|
74
|
+
}
|
|
75
|
+
const entryCount = u16(eocd + 10);
|
|
76
|
+
const directorySize = u32(eocd + 12);
|
|
77
|
+
const directoryOffset = u32(eocd + 16);
|
|
78
|
+
if (entryCount === ZIP64_MARKER_16 ||
|
|
79
|
+
directoryOffset === ZIP64_MARKER_32 ||
|
|
80
|
+
directorySize === ZIP64_MARKER_32 ||
|
|
81
|
+
(eocd >= 20 && view.getUint32(eocd - 20, true) === ZIP64_LOCATOR_SIGNATURE)) {
|
|
82
|
+
throw new VbaParseError('unsupported', 'package: ZIP64 archives are not supported');
|
|
83
|
+
}
|
|
84
|
+
if (entryCount > ZIP_LIMITS.maxEntries) {
|
|
85
|
+
throw new VbaParseError('too-large', 'package: too many entries');
|
|
86
|
+
}
|
|
87
|
+
if (directoryOffset + directorySize > bytes.length) {
|
|
88
|
+
throw new VbaParseError('malformed', 'package: central directory lies outside the file');
|
|
89
|
+
}
|
|
90
|
+
const decoder = new TextDecoder('utf-8');
|
|
91
|
+
const byName = new Map();
|
|
92
|
+
const entries = [];
|
|
93
|
+
const directoryEnd = directoryOffset + directorySize;
|
|
94
|
+
let at = directoryOffset;
|
|
95
|
+
for (let i = 0; i < entryCount; i += 1) {
|
|
96
|
+
// Confined to the region the archive declared for its directory, so a
|
|
97
|
+
// crafted file cannot have us read "entries" out of arbitrary bytes
|
|
98
|
+
// elsewhere in the package.
|
|
99
|
+
if (at + 46 > directoryEnd) {
|
|
100
|
+
throw new VbaParseError('malformed', 'package: central directory is shorter than it claims');
|
|
101
|
+
}
|
|
102
|
+
if (u32(at) !== CENTRAL_SIGNATURE) {
|
|
103
|
+
throw new VbaParseError('malformed', 'package: damaged central directory');
|
|
104
|
+
}
|
|
105
|
+
const flags = u16(at + 8);
|
|
106
|
+
const method = u16(at + 10);
|
|
107
|
+
const compressedSize = u32(at + 20);
|
|
108
|
+
const uncompressedSize = u32(at + 24);
|
|
109
|
+
const nameLength = u16(at + 28);
|
|
110
|
+
const extraLength = u16(at + 30);
|
|
111
|
+
const commentLength = u16(at + 32);
|
|
112
|
+
const localOffset = u32(at + 42);
|
|
113
|
+
if (nameLength > ZIP_LIMITS.maxNameLength) {
|
|
114
|
+
throw new VbaParseError('too-large', 'package: entry name is too long');
|
|
115
|
+
}
|
|
116
|
+
if (compressedSize === ZIP64_MARKER_32 || uncompressedSize === ZIP64_MARKER_32) {
|
|
117
|
+
throw new VbaParseError('unsupported', 'package: ZIP64 entry sizes are not supported');
|
|
118
|
+
}
|
|
119
|
+
const nameStart = at + 46;
|
|
120
|
+
if (nameStart + nameLength > directoryEnd) {
|
|
121
|
+
throw new VbaParseError('malformed', 'package: entry name lies outside the central directory');
|
|
122
|
+
}
|
|
123
|
+
// OOXML part names are ASCII, which UTF-8 and CP437 agree on — so the
|
|
124
|
+
// flag-11 distinction cannot change the names we look up.
|
|
125
|
+
const name = decoder.decode(bytes.subarray(nameStart, nameStart + nameLength));
|
|
126
|
+
const entry = {
|
|
127
|
+
name,
|
|
128
|
+
method,
|
|
129
|
+
compressedSize,
|
|
130
|
+
uncompressedSize,
|
|
131
|
+
localOffset,
|
|
132
|
+
encrypted: (flags & 0x0001) !== 0,
|
|
133
|
+
};
|
|
134
|
+
entries.push({ name, compressedSize, uncompressedSize });
|
|
135
|
+
// First entry wins, so a duplicated name cannot shadow the part that was
|
|
136
|
+
// already announced in `entries`.
|
|
137
|
+
if (!byName.has(name))
|
|
138
|
+
byName.set(name, entry);
|
|
139
|
+
at = nameStart + nameLength + extraLength + commentLength;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
entries,
|
|
143
|
+
has: (name) => byName.has(name),
|
|
144
|
+
async read(name, options) {
|
|
145
|
+
const entry = byName.get(name);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
throw new VbaParseError('malformed', `package: no such part (${name})`);
|
|
148
|
+
}
|
|
149
|
+
if (entry.encrypted) {
|
|
150
|
+
throw new VbaParseError('unsupported', `package: ${name} is encrypted`);
|
|
151
|
+
}
|
|
152
|
+
const requested = options?.maxBytes;
|
|
153
|
+
const cap = Math.min(typeof requested === 'number' && Number.isFinite(requested) && requested > 0
|
|
154
|
+
? Math.floor(requested)
|
|
155
|
+
: ZIP_LIMITS.maxEntryBytes, ZIP_LIMITS.maxEntryBytes);
|
|
156
|
+
// The declared size is only a claim, so it is a cheap early rejection
|
|
157
|
+
// rather than the real defence — the real one is enforced against the
|
|
158
|
+
// bytes actually produced, below.
|
|
159
|
+
if (entry.uncompressedSize > cap) {
|
|
160
|
+
throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
|
|
161
|
+
}
|
|
162
|
+
if (u32(entry.localOffset) !== LOCAL_SIGNATURE) {
|
|
163
|
+
throw new VbaParseError('malformed', `package: ${name} has a damaged local header`);
|
|
164
|
+
}
|
|
165
|
+
const localNameLength = u16(entry.localOffset + 26);
|
|
166
|
+
const localExtraLength = u16(entry.localOffset + 28);
|
|
167
|
+
const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength;
|
|
168
|
+
const dataEnd = dataStart + entry.compressedSize;
|
|
169
|
+
if (dataEnd > bytes.length) {
|
|
170
|
+
throw new VbaParseError('malformed', `package: ${name} runs past the end of the file`);
|
|
171
|
+
}
|
|
172
|
+
const data = bytes.subarray(dataStart, dataEnd);
|
|
173
|
+
if (entry.method === METHOD_STORED) {
|
|
174
|
+
// A stored entry's real length is its compressed length; the declared
|
|
175
|
+
// uncompressed size may disagree, so the cap is applied to what we
|
|
176
|
+
// would actually return.
|
|
177
|
+
if (data.length > cap) {
|
|
178
|
+
throw new VbaParseError('too-large', `package: ${name} exceeds the part size cap`);
|
|
179
|
+
}
|
|
180
|
+
return data.slice();
|
|
181
|
+
}
|
|
182
|
+
if (entry.method !== METHOD_DEFLATE) {
|
|
183
|
+
throw new VbaParseError('unsupported', `package: ${name} uses compression method ${entry.method}`);
|
|
184
|
+
}
|
|
185
|
+
return inflateRaw(data, name, cap);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Inflates one entry, enforcing `cap` as the bytes arrive.
|
|
191
|
+
*
|
|
192
|
+
* Reading the stream chunk by chunk rather than with `Response.arrayBuffer()`
|
|
193
|
+
* is the whole point. Buffering first and checking the length afterwards
|
|
194
|
+
* means a part that declares a kilobyte and expands to a gigabyte has already
|
|
195
|
+
* been fully materialized by the time the limit is consulted — the classic
|
|
196
|
+
* decompression bomb. Here the read is abandoned the moment the total would
|
|
197
|
+
* exceed the cap.
|
|
198
|
+
*/
|
|
199
|
+
async function inflateRaw(data, name, cap) {
|
|
200
|
+
if (typeof DecompressionStream !== 'function' || typeof ReadableStream !== 'function') {
|
|
201
|
+
throw new VbaParseError('unsupported', 'package: this environment cannot decompress ZIP entries');
|
|
202
|
+
}
|
|
203
|
+
// The bytes are wrapped in a stream directly rather than via
|
|
204
|
+
// `new Blob([data]).stream()`. Both work in a browser, but `Blob.stream` is
|
|
205
|
+
// one of the pieces jsdom does not implement — and a host's own test suite
|
|
206
|
+
// running in jsdom is a place this code has to work, not fall over.
|
|
207
|
+
// `BufferSource` and not `Uint8Array`: that is what `DecompressionStream`
|
|
208
|
+
// declares its writable side accepts, and `pipeThrough` matches on it.
|
|
209
|
+
const source = new ReadableStream({
|
|
210
|
+
start(controller) {
|
|
211
|
+
// The cast covers a modelling gap, not a real one: since TypeScript 5.7
|
|
212
|
+
// a bare `Uint8Array` may be backed by a `SharedArrayBuffer`, which
|
|
213
|
+
// `BufferSource` excludes. These bytes are a view over the caller's
|
|
214
|
+
// package, and the stream accepts any array-buffer view at runtime.
|
|
215
|
+
// Copying to prove it to the compiler would duplicate the whole part.
|
|
216
|
+
controller.enqueue(data);
|
|
217
|
+
controller.close();
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const stream = source.pipeThrough(new DecompressionStream('deflate-raw'));
|
|
221
|
+
const reader = stream.getReader();
|
|
222
|
+
const chunks = [];
|
|
223
|
+
let total = 0;
|
|
224
|
+
try {
|
|
225
|
+
for (;;) {
|
|
226
|
+
const { done, value } = await reader.read();
|
|
227
|
+
if (done)
|
|
228
|
+
break;
|
|
229
|
+
if (!value)
|
|
230
|
+
continue;
|
|
231
|
+
total += value.byteLength;
|
|
232
|
+
if (total > cap) {
|
|
233
|
+
throw new VbaParseError('too-large', `package: ${name} inflates past the part size cap`);
|
|
234
|
+
}
|
|
235
|
+
chunks.push(value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
// Release the decompressor before rethrowing, so an abandoned bomb does
|
|
240
|
+
// not leave a stream pulling in the background.
|
|
241
|
+
await reader.cancel().catch(() => undefined);
|
|
242
|
+
if (error instanceof VbaParseError)
|
|
243
|
+
throw error;
|
|
244
|
+
throw new VbaParseError('malformed', `package: ${name} could not be decompressed`);
|
|
245
|
+
}
|
|
246
|
+
const out = new Uint8Array(total);
|
|
247
|
+
let at = 0;
|
|
248
|
+
for (const chunk of chunks) {
|
|
249
|
+
out.set(chunk, at);
|
|
250
|
+
at += chunk.byteLength;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,4 +10,14 @@ export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning
|
|
|
10
10
|
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
|
|
11
11
|
export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
|
|
12
12
|
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
|
13
|
+
/**
|
|
14
|
+
* Reading the VBA macros already inside a `.docm`, for review only. Separate
|
|
15
|
+
* from `MacroKit` on purpose: extraction touches no saved macro, binds no
|
|
16
|
+
* shortcut, and executes nothing — see `src/import/vba.ts`.
|
|
17
|
+
*/
|
|
18
|
+
export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, type VbaExtraction, type VbaFailureReason, type VbaModule, type VbaModuleKind, type VbaProcedureRef, type VbaProject, type VbaWarning, } from './import/vba.js';
|
|
19
|
+
export { VbaParseError, isVbaParseError, type VbaParseErrorCode } from './import/errors.js';
|
|
20
|
+
export { readCfb, CFB_LIMITS, type CfbContainer, type CfbEntry, type CfbEntryType } from './import/cfb.js';
|
|
21
|
+
export { openZip, ZIP_LIMITS, type ZipArchive, type ZipEntry } from './import/zip.js';
|
|
22
|
+
export { decompressOvba, OVBA_LIMITS, type DecompressOvbaOptions } from './import/ms-ovba.js';
|
|
13
23
|
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
package/dist/index.js
CHANGED
|
@@ -8,4 +8,14 @@ export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
|
|
|
8
8
|
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
|
|
9
9
|
export { AutoText } from './snippets/autotext.js';
|
|
10
10
|
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
|
|
11
|
+
/**
|
|
12
|
+
* Reading the VBA macros already inside a `.docm`, for review only. Separate
|
|
13
|
+
* from `MacroKit` on purpose: extraction touches no saved macro, binds no
|
|
14
|
+
* shortcut, and executes nothing — see `src/import/vba.ts`.
|
|
15
|
+
*/
|
|
16
|
+
export { extractVbaFromDocx, extractVbaProject, findVbaPart, scanForAutoRunProcedures, VBA_LIMITS, } from './import/vba.js';
|
|
17
|
+
export { VbaParseError, isVbaParseError } from './import/errors.js';
|
|
18
|
+
export { readCfb, CFB_LIMITS } from './import/cfb.js';
|
|
19
|
+
export { openZip, ZIP_LIMITS } from './import/zip.js';
|
|
20
|
+
export { decompressOvba, OVBA_LIMITS } from './import/ms-ovba.js';
|
|
11
21
|
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.js';
|
package/dist/manager.d.ts
CHANGED
|
@@ -108,13 +108,19 @@ export declare class MacroKit {
|
|
|
108
108
|
runSource(source: string): Promise<MacroRunResult>;
|
|
109
109
|
get isRecording(): boolean;
|
|
110
110
|
get recordedStepCount(): number;
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Starts a fresh recording. Returns false instead of overwriting an active
|
|
113
|
+
* or stopped-but-unsaved capture, or while another macro is running.
|
|
114
|
+
*/
|
|
115
|
+
startRecording(): boolean;
|
|
112
116
|
/**
|
|
113
117
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
114
118
|
* save.
|
|
115
119
|
*
|
|
116
120
|
* Throws — with the stopped recording **retained for retry** (call again;
|
|
117
121
|
* `cancelRecording` is the explicit way to drop it) — when:
|
|
122
|
+
* - `recording-uncapturable`: every action was uncapturable, so even an
|
|
123
|
+
* explicitly incomplete save would create an empty macro.
|
|
118
124
|
* - `recording-incomplete`: some actions could not be captured (e.g. an
|
|
119
125
|
* inserted image, whose payload is the whole file). Saving that as-is
|
|
120
126
|
* would present a macro that replays less than what the user did, so it
|
package/dist/manager.js
CHANGED
|
@@ -223,10 +223,15 @@ export class MacroKit {
|
|
|
223
223
|
get recordedStepCount() {
|
|
224
224
|
return this.recorder.stepCount;
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Starts a fresh recording. Returns false instead of overwriting an active
|
|
228
|
+
* or stopped-but-unsaved capture, or while another macro is running.
|
|
229
|
+
*/
|
|
226
230
|
startRecording() {
|
|
227
|
-
if (this.running)
|
|
228
|
-
return;
|
|
231
|
+
if (this.running || this.recorder.recording || this.recorder.hasPending)
|
|
232
|
+
return false;
|
|
229
233
|
this.recorder.start();
|
|
234
|
+
return this.recorder.recording;
|
|
230
235
|
}
|
|
231
236
|
/**
|
|
232
237
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
@@ -234,6 +239,8 @@ export class MacroKit {
|
|
|
234
239
|
*
|
|
235
240
|
* Throws — with the stopped recording **retained for retry** (call again;
|
|
236
241
|
* `cancelRecording` is the explicit way to drop it) — when:
|
|
242
|
+
* - `recording-uncapturable`: every action was uncapturable, so even an
|
|
243
|
+
* explicitly incomplete save would create an empty macro.
|
|
237
244
|
* - `recording-incomplete`: some actions could not be captured (e.g. an
|
|
238
245
|
* inserted image, whose payload is the whole file). Saving that as-is
|
|
239
246
|
* would present a macro that replays less than what the user did, so it
|
|
@@ -243,19 +250,27 @@ export class MacroKit {
|
|
|
243
250
|
* - a persistence failure (quota, oversized state).
|
|
244
251
|
*/
|
|
245
252
|
stopRecording(name, shortcut, options = {}) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
// Stop first. Every failure below must leave the recorder inactive and
|
|
254
|
+
// its snapshot retryable. Checking capacity before stop() used to leave a
|
|
255
|
+
// manual recording active behind a stopped UI, and could lose an
|
|
256
|
+
// auto-stopped recording on the next start.
|
|
249
257
|
const pending = this.recorder.stop();
|
|
250
258
|
const { steps, truncated } = splitOversizedSteps(pending.steps);
|
|
251
259
|
if (truncated)
|
|
252
260
|
throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
|
|
261
|
+
const commandIds = [...new Set(pending.warnings.map((warning) => warning.commandId))].join(', ');
|
|
262
|
+
if (pending.warnings.length > 0 && steps.length === 0) {
|
|
263
|
+
const messages = macroMessages();
|
|
264
|
+
throw new MacroError(messages.recordingUncapturable?.(commandIds) ?? messages.recordingIncomplete(commandIds), 'recording-uncapturable');
|
|
265
|
+
}
|
|
253
266
|
if (steps.length === 0) {
|
|
254
267
|
this.recorder.discard();
|
|
255
268
|
return null;
|
|
256
269
|
}
|
|
270
|
+
this.requireValidShortcut(shortcut);
|
|
271
|
+
this.requireItemLimits({ name, shortcut });
|
|
272
|
+
this.requireRoom(this.state.recordings);
|
|
257
273
|
if (pending.warnings.length > 0 && !options.allowIncomplete) {
|
|
258
|
-
const commandIds = [...new Set(pending.warnings.map((warning) => warning.commandId))].join(', ');
|
|
259
274
|
throw new MacroError(macroMessages().recordingIncomplete(commandIds), 'recording-incomplete');
|
|
260
275
|
}
|
|
261
276
|
const recording = {
|
package/dist/messages.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface MacroMessages {
|
|
|
43
43
|
saveFailed: string;
|
|
44
44
|
recordingTooLarge: string;
|
|
45
45
|
recordingIncomplete: (commandIds: string) => string;
|
|
46
|
+
/**
|
|
47
|
+
* No usable step was captured; saving with allowIncomplete would create an
|
|
48
|
+
* empty macro. Optional for source compatibility with existing full locale
|
|
49
|
+
* objects compiled against 0.7.0.
|
|
50
|
+
*/
|
|
51
|
+
recordingUncapturable?: (commandIds: string) => string;
|
|
46
52
|
shortcutInvalid: string;
|
|
47
53
|
shortcutNeedsModifier: string;
|
|
48
54
|
shortcutReserved: string;
|
package/dist/messages.js
CHANGED
|
@@ -43,6 +43,7 @@ export const ENGLISH_MESSAGES = {
|
|
|
43
43
|
saveFailed: 'Saving failed — the change was not applied',
|
|
44
44
|
recordingTooLarge: 'The recording is too large to save',
|
|
45
45
|
recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
|
|
46
|
+
recordingUncapturable: (commandIds) => `The recording contains only actions that cannot be recorded (${commandIds})`,
|
|
46
47
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
47
48
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
48
49
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -83,6 +84,7 @@ export const HEBREW_MESSAGES = {
|
|
|
83
84
|
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
84
85
|
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
85
86
|
recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
87
|
+
recordingUncapturable: (commandIds) => `ההקלטה מכילה רק פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
86
88
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
87
89
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
88
90
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -80,6 +80,13 @@ export declare class MacroRecorder {
|
|
|
80
80
|
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
81
81
|
private teardown;
|
|
82
82
|
private push;
|
|
83
|
+
private addWarning;
|
|
84
|
+
/**
|
|
85
|
+
* Warnings are part of the stopped snapshot just like steps. Counting both
|
|
86
|
+
* prevents a warning-only recording from growing without bound while never
|
|
87
|
+
* reaching the ordinary step cap.
|
|
88
|
+
*/
|
|
89
|
+
private maybeAutoStop;
|
|
83
90
|
/**
|
|
84
91
|
* Records a programmatic insertion the host will not report as typing —
|
|
85
92
|
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
@@ -146,12 +146,27 @@ export class MacroRecorder {
|
|
|
146
146
|
}
|
|
147
147
|
push(step) {
|
|
148
148
|
this.steps.push(step);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
this.maybeAutoStop();
|
|
150
|
+
}
|
|
151
|
+
addWarning(warning) {
|
|
152
|
+
this.warnings.push(warning);
|
|
153
|
+
this.maybeAutoStop();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Warnings are part of the stopped snapshot just like steps. Counting both
|
|
157
|
+
* prevents a warning-only recording from growing without bound while never
|
|
158
|
+
* reaching the ordinary step cap.
|
|
159
|
+
*/
|
|
160
|
+
maybeAutoStop() {
|
|
161
|
+
if (!this.active)
|
|
162
|
+
return;
|
|
163
|
+
if (this.steps.length + this.warnings.length < this.maxSteps)
|
|
164
|
+
return;
|
|
165
|
+
this.teardown();
|
|
166
|
+
this.pending = { steps: this.steps, warnings: this.warnings };
|
|
167
|
+
this.steps = [];
|
|
168
|
+
this.warnings = [];
|
|
169
|
+
this.onAutoStop?.();
|
|
155
170
|
}
|
|
156
171
|
/**
|
|
157
172
|
* Records a programmatic insertion the host will not report as typing —
|
|
@@ -184,15 +199,15 @@ export class MacroRecorder {
|
|
|
184
199
|
json = JSON.stringify(payload);
|
|
185
200
|
}
|
|
186
201
|
catch {
|
|
187
|
-
this.
|
|
202
|
+
this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
|
|
188
203
|
return;
|
|
189
204
|
}
|
|
190
205
|
if (typeof json !== 'string') {
|
|
191
|
-
this.
|
|
206
|
+
this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
|
|
192
207
|
return;
|
|
193
208
|
}
|
|
194
209
|
if (json.length > IMPORT_LIMITS.maxPayloadLength) {
|
|
195
|
-
this.
|
|
210
|
+
this.addWarning({ commandId: id, reason: 'payload-too-large' });
|
|
196
211
|
return;
|
|
197
212
|
}
|
|
198
213
|
this.push({ type: 'command', id, payload: JSON.parse(json) });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdoc-macros",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder,
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, snippets with auto-text expansion, and read-only VBA import from .docm files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"snippets",
|
|
35
35
|
"autotext",
|
|
36
36
|
"docx",
|
|
37
|
+
"docm",
|
|
38
|
+
"vba",
|
|
37
39
|
"editor",
|
|
38
40
|
"otzaria"
|
|
39
41
|
],
|