@embedpdf/plugin-actions 3.0.0-next.14 → 3.0.0-next.16

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.
Files changed (41) hide show
  1. package/dist/contract/host.cjs +13 -1
  2. package/dist/contract/host.cjs.map +1 -1
  3. package/dist/contract/host.d.cts +3 -7
  4. package/dist/contract/host.d.ts +3 -7
  5. package/dist/contract/host.js +10 -3
  6. package/dist/contract/host.js.map +1 -1
  7. package/dist/contract-BTPW8b4L.d.cts +557 -0
  8. package/dist/contract-Cn-7mBY8.d.ts +557 -0
  9. package/dist/contract-CwN3NAXU.js +175 -0
  10. package/dist/contract-CwN3NAXU.js.map +1 -0
  11. package/dist/contract-_HB_UliT.cjs +210 -0
  12. package/dist/contract-_HB_UliT.cjs.map +1 -0
  13. package/dist/contract.cjs +5 -4
  14. package/dist/contract.d.cts +2 -3
  15. package/dist/contract.d.ts +2 -3
  16. package/dist/contract.js +2 -3
  17. package/dist/host-contract-BbODswDs.d.ts +165 -0
  18. package/dist/host-contract-D22Gz-qt.d.cts +165 -0
  19. package/dist/index.cjs +1588 -1072
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +11 -9
  22. package/dist/index.d.ts +11 -9
  23. package/dist/index.js +1586 -1072
  24. package/dist/index.js.map +1 -1
  25. package/package.json +7 -11
  26. package/dist/contract-C1K3H4RP.cjs +0 -89
  27. package/dist/contract-C1K3H4RP.cjs.map +0 -1
  28. package/dist/contract-COk-w3Fo.d.ts +0 -50
  29. package/dist/contract-DAem-EST.js +0 -78
  30. package/dist/contract-DAem-EST.js.map +0 -1
  31. package/dist/contract-Os5y4NaD.d.cts +0 -50
  32. package/dist/internal.cjs +0 -3
  33. package/dist/internal.d.cts +0 -3
  34. package/dist/internal.d.ts +0 -3
  35. package/dist/internal.js +0 -2
  36. package/dist/types-BXJLAKXi.cjs +0 -52
  37. package/dist/types-BXJLAKXi.cjs.map +0 -1
  38. package/dist/types-D-DoiESj.d.cts +0 -504
  39. package/dist/types-D-DoiESj.d.ts +0 -504
  40. package/dist/types-DgBMy5nE.js +0 -35
  41. package/dist/types-DgBMy5nE.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,67 +1,290 @@
1
- import { n as eventOf, r as originOf, t as ActionsToken } from "./types-DgBMy5nE.js";
2
- import { n as createHoverPump, t as submitEntriesToUrlEncoded } from "./contract-DAem-EST.js";
3
- import { DocumentsToken, createEventHook, createSerialQueue, definePlugin } from "@embedpdf/core";
1
+ import { a as createHoverPump, i as submitEntriesToUrlEncoded, n as eventOf, o as ActionsToken, r as triggerOriginOf, t as ACTIONS_DEFAULTS } from "./contract-CwN3NAXU.js";
2
+ import { ActionsToken as ActionsToken$1 } from "./contract/host.js";
3
+ import { DocumentsToken, PluginError, composeApi, definePlugin, isPluginError, toPageRef } from "@embedpdf/core";
4
+ import { annotationKey, pdfRectOf, rgbOf, toPageRef as toPageRef$1 } from "@embedpdf/engine-core/runtime";
4
5
  import { DEFAULT_SCRIPT_BUDGET, createScriptHost, javaScriptProgramFromActionTree, resolveScriptIdentity, scriptFieldsFromSnapshot, seedFrom } from "@embedpdf/core-acrojs";
5
- //#region src/script-environment.ts
6
- function createScriptRealmFactory(js, identityDoc) {
7
- const sandboxFactory = js.sandboxFactory ?? (() => import("@embedpdf/core-js-sandbox").then(({ createQuickJsSandbox }) => createQuickJsSandbox()));
8
- const budget = js.budget ?? DEFAULT_SCRIPT_BUDGET;
6
+ //#region src/services/annotation-rows.ts
7
+ /**
8
+ * The annotations an action can live on, of both read families: a page's
9
+ * annotations (`doc.annotations`, `doc.annotate.read`) and its widgets, which
10
+ * come with the form (`ActionsForm`, `doc.forms.read`). Each family is read
11
+ * only when the session may read it, so a fill-only session still finds its
12
+ * widgets' actions.
13
+ */
14
+ /** Every annotation of a page the session may read: its annotations, then its widgets. */
15
+ async function readPageAnnotations(ctx, form, page, read = (task) => task) {
16
+ const [annotations, widgets] = await Promise.all([ctx.allows("doc.annotate.read") ? read(ctx.doc.page(page).annotations.list()).then((list) => list.annotations) : Promise.resolve([]), ctx.allows("doc.forms.read") ? form.read(read).then((snapshot) => snapshot.widgets.filter((widget) => widget.page.objectNumber === page.objectNumber)) : Promise.resolve([])]);
17
+ return [...annotations, ...widgets];
18
+ }
19
+ /**
20
+ * The annotation `ref` names, of either family, or `null` when the session
21
+ * can't see it: the form's widgets first (most actions a viewer runs are a
22
+ * field's), then the page's annotations.
23
+ */
24
+ async function readAnnotation(ctx, form, ref, read = (task) => task) {
25
+ const key = annotationKey(ref);
26
+ const matches = (candidate) => annotationKey(candidate.ref) === key;
27
+ if (ctx.allows("doc.forms.read")) {
28
+ const widget = (await form.read(read)).widgets.find(matches);
29
+ if (widget) return widget;
30
+ }
31
+ if (!ctx.allows("doc.annotate.read")) return null;
32
+ return (await read(ctx.doc.page(ref.page).annotations.list())).annotations.find(matches) ?? null;
33
+ }
34
+ //#endregion
35
+ //#region src/dispatch/fold.ts
36
+ const foldSteps = (steps, diagnostics) => {
37
+ const statuses = steps.map((step) => step.result.status);
9
38
  return {
10
- budget,
11
- realmFor: (target) => createScriptHost({
12
- sandboxFactory,
13
- document: () => {
14
- const meta = target.document();
15
- return {
16
- id: target.doc.id,
17
- fileName: js.fileName?.() ?? meta?.name ?? "document.pdf",
18
- pageCount: meta?.pageCount ?? 0,
19
- pageNumber: 0
20
- };
21
- },
22
- identity: () => resolveScriptIdentity(identityDoc, js.identity),
23
- environment: (sequence) => {
24
- const nowMs = js.now?.() ?? Date.now();
39
+ status: steps.length === 0 ? "inert" : statuses.every((stepStatus) => stepStatus === "refused") ? "refused" : statuses.every((stepStatus) => stepStatus === "inert") ? "inert" : statuses.every((stepStatus) => stepStatus === "executed") ? "executed" : "partial",
40
+ steps,
41
+ diagnostics
42
+ };
43
+ };
44
+ //#endregion
45
+ //#region src/dispatch/dispatcher.ts
46
+ /**
47
+ * The dispatcher: the one serialized queue every tree rides. `execute` runs
48
+ * a resolved tree; `dispatch` resolves a trigger inside the queued operation
49
+ * (submission order is execution order) and fans out through the lifecycle
50
+ * areas. `runAndEmit` is the shared per-tree unit: run, then `onExecuted`.
51
+ *
52
+ * A caller's signal skips an operation that hasn't started, stops a
53
+ * trigger's reads, and stops a tree before its next node. `execute` then
54
+ * rejects `operation-cancelled`; `dispatch` never rejects, so it resolves
55
+ * `cancelled`.
56
+ */
57
+ /** The source a trigger reports for: what its diagnostics carry before a tree is found. */
58
+ const sourceOfTrigger = (trigger) => {
59
+ switch (trigger.scope) {
60
+ case "activate":
61
+ case "annotation": return trigger.source ?? {
62
+ kind: "annotation",
63
+ annotation: trigger.ref,
64
+ page: trigger.page
65
+ };
66
+ case "page": return {
67
+ kind: "page",
68
+ page: trigger.page
69
+ };
70
+ case "document": return { kind: "document" };
71
+ }
72
+ };
73
+ const cancelled = (operation, signal) => new PluginError("operation-cancelled", "actions", `${operation} was cancelled`, { cause: signal?.reason });
74
+ function createDispatcher(ctx, services, { run }, { triggerEnabled, lifecycleAnnotationsOf, planPageSteps }, openSequence, { runDocumentEventOp }) {
75
+ const { executed, reportDiagnostic } = services.events;
76
+ const { decisionFor } = services.policy;
77
+ const { allowsPrint } = services.authority;
78
+ const { enqueue, budget } = services.queue;
79
+ const { readDocumentActions, DOC_EVENT_TREES } = services.catalog;
80
+ const { form } = services;
81
+ const { noteUserActivity } = openSequence;
82
+ /** Run one tree and announce it: the shared per-tree unit (queued by
83
+ * `execute`; called inline per step by the queued trigger resolver). */
84
+ const runAndEmit = async (tree, actionContext, signal) => {
85
+ const result = await run(tree, actionContext, signal);
86
+ executed.emit({
87
+ tree,
88
+ result,
89
+ source: actionContext.source
90
+ });
91
+ return result;
92
+ };
93
+ const execute = (tree, actionContext, options) => {
94
+ const signal = options?.signal;
95
+ if (actionContext.origin === "user") noteUserActivity();
96
+ return enqueue(async () => {
97
+ budget.scriptNodes = 0;
98
+ const result = await runAndEmit(tree, actionContext, signal);
99
+ if (result.status === "cancelled") throw cancelled("actions.execute", signal);
100
+ return result;
101
+ }, options);
102
+ };
103
+ const canExecute = (tree, actionContext) => {
104
+ if (tree.incomplete || !tree.root) return false;
105
+ const decision = decisionFor(tree.root, actionContext.origin);
106
+ return decision === "allow" || decision === "adapter";
107
+ };
108
+ const runSteps = async (steps, origin, event, diagnostics, signal) => {
109
+ const results = [];
110
+ for (const step of steps) {
111
+ if (signal?.aborted) break;
112
+ const result = await runAndEmit(step.tree, {
113
+ origin,
114
+ source: step.source,
115
+ event
116
+ }, signal);
117
+ results.push({
118
+ source: step.source,
119
+ tree: step.tree,
120
+ result
121
+ });
122
+ }
123
+ if (signal?.aborted) return {
124
+ status: "cancelled",
125
+ steps: results,
126
+ diagnostics
127
+ };
128
+ return foldSteps(results, diagnostics);
129
+ };
130
+ /** Everything a trigger needs, reads included, inside the queued operation:
131
+ * submission order is execution order. Never throws. */
132
+ const resolveAndRun = async (trigger, signal) => {
133
+ const diagnostics = [];
134
+ const diagnose = (diagnostic) => {
135
+ diagnostics.push(diagnostic);
136
+ reportDiagnostic(diagnostic, { source: sourceOfTrigger(trigger) });
137
+ };
138
+ /** A read the caller's signal can stop. */
139
+ const read = (task) => ctx.cancellable(signal, task);
140
+ try {
141
+ if (!triggerEnabled(trigger)) {
142
+ diagnose({
143
+ code: "trigger-disabled",
144
+ message: `${trigger.scope}: trigger family disabled by config`
145
+ });
25
146
  return {
26
- nowMs,
27
- utcOffsetMinutes: js.utcOffsetMinutes?.() ?? -new Date(nowMs).getTimezoneOffset(),
28
- randomSeed: js.randomSeed?.() ?? seedFrom(target.doc.id, sequence)
147
+ status: "inert",
148
+ steps: [],
149
+ diagnostics
29
150
  };
30
- },
31
- bootSources: target.bootSources,
32
- budget
33
- })
151
+ }
152
+ const origin = triggerOriginOf(trigger);
153
+ switch (trigger.scope) {
154
+ case "activate":
155
+ case "annotation": {
156
+ const event = trigger.scope === "activate" ? "activate" : trigger.event;
157
+ const annotation = await readAnnotation(ctx, form, trigger.ref, read);
158
+ if (event === "mouseUp" && annotation?.actions?.activate) return {
159
+ status: "inert",
160
+ steps: [],
161
+ diagnostics
162
+ };
163
+ const tree = annotation?.actions?.[event];
164
+ if (!tree?.root && !tree?.incomplete) return {
165
+ status: "inert",
166
+ steps: [],
167
+ diagnostics
168
+ };
169
+ const source = sourceOfTrigger(trigger);
170
+ return await runSteps([{
171
+ source,
172
+ tree
173
+ }], origin, eventOf(trigger), diagnostics, signal);
174
+ }
175
+ case "page": {
176
+ const lifecycle = await lifecycleAnnotationsOf(trigger.page);
177
+ const pageActions = ctx.getPage(trigger.page)?.actions;
178
+ const steps = planPageSteps(trigger.event, trigger.page, pageActions, lifecycle);
179
+ return await runSteps(steps, origin, eventOf(trigger), diagnostics, signal);
180
+ }
181
+ case "document":
182
+ if (trigger.event !== "open") return await runDocumentEventOp(trigger.event, diagnostics, diagnose);
183
+ if (!openSequence.claim()) {
184
+ diagnose({
185
+ code: "open-sequence-replayed",
186
+ message: "document open sequence already fired for this document"
187
+ });
188
+ return {
189
+ status: "inert",
190
+ steps: [],
191
+ diagnostics
192
+ };
193
+ }
194
+ return await openSequence.run();
195
+ }
196
+ } catch (error) {
197
+ if (isPluginError(error, "operation-cancelled") && signal?.aborted) return {
198
+ status: "cancelled",
199
+ steps: [],
200
+ diagnostics
201
+ };
202
+ diagnose({
203
+ code: "trigger-failed",
204
+ message: `trigger resolution failed: ${error instanceof Error ? error.message : String(error)}`
205
+ });
206
+ return {
207
+ status: "refused",
208
+ steps: [],
209
+ diagnostics
210
+ };
211
+ }
212
+ };
213
+ const dispatch = (trigger, options) => {
214
+ const signal = options?.signal;
215
+ if (triggerOriginOf(trigger) === "user") noteUserActivity();
216
+ return enqueue(() => {
217
+ budget.scriptNodes = 0;
218
+ return resolveAndRun(trigger, signal);
219
+ }, options).catch(() => ({
220
+ status: "cancelled",
221
+ steps: [],
222
+ diagnostics: []
223
+ }));
224
+ };
225
+ /** The tree `executeNamed` runs, and its context: a click from your code unless `context` says otherwise. */
226
+ const namedTree = (name) => ({
227
+ root: {
228
+ type: "named",
229
+ subtype: "Named",
230
+ name,
231
+ next: []
232
+ },
233
+ incomplete: false,
234
+ warningFlags: 0,
235
+ warnings: []
236
+ });
237
+ const namedContext = (context) => ({
238
+ origin: "user",
239
+ source: { kind: "api" },
240
+ event: { scope: "activate" },
241
+ ...context
242
+ });
243
+ const executeNamed = (name, context, options) => execute(namedTree(name), namedContext(context), options);
244
+ const canExecuteNamed = (name, context) => canExecute(namedTree(name), namedContext(context)) && (name !== "Print" || allowsPrint());
245
+ /** The raw tree behind a source, read from the document, with no dispatch
246
+ * rules applied (the /A-shadows-U rule lives in `resolveAndRun`). */
247
+ const readActionTree = async (source) => {
248
+ switch (source.kind) {
249
+ case "annotation": return (await readAnnotation(ctx, form, source.annotation))?.actions?.[source.event ?? "activate"] ?? null;
250
+ case "field": {
251
+ const { fields } = await form.read();
252
+ const target = source.field;
253
+ return fields.find((candidate) => target.kind === "objectNumber" ? candidate.ref.kind === "objectNumber" && candidate.ref.objectNumber === target.objectNumber : candidate.name === target.name)?.actions?.[source.event] ?? null;
254
+ }
255
+ case "page": return ctx.getPage(source.page)?.actions?.[source.event ?? "open"] ?? null;
256
+ case "outline": {
257
+ const { items } = await ctx.doc.outline.get();
258
+ return items.find((candidate) => candidate.ref.objectNumber === source.item.objectNumber)?.action ?? null;
259
+ }
260
+ case "document": {
261
+ const snapshot = await readDocumentActions();
262
+ if (!snapshot) return null;
263
+ const event = source.event ?? "open";
264
+ return (event === "open" ? snapshot.openAction : snapshot[DOC_EVENT_TREES[event]]) ?? null;
265
+ }
266
+ }
267
+ };
268
+ return {
269
+ runAndEmit,
270
+ dispatch,
271
+ api: {
272
+ execute,
273
+ canExecute,
274
+ executeNamed,
275
+ canExecuteNamed,
276
+ getActionTree: (source, options) => ctx.cancellable(options?.signal, readActionTree(source)),
277
+ dispatch,
278
+ canDispatch: (trigger) => triggerEnabled(trigger),
279
+ prepareClose: (options) => dispatch({
280
+ scope: "document",
281
+ event: "will-close"
282
+ }, options)
283
+ }
34
284
  };
35
285
  }
36
286
  //#endregion
37
- //#region src/capability.ts
38
- const ALLOW_ALL = {
39
- user: "allow",
40
- hover: "allow",
41
- lifecycle: "allow"
42
- };
43
- const DEFAULT_POLICY = {
44
- goto: ALLOW_ALL,
45
- named: ALLOW_ALL,
46
- hide: ALLOW_ALL,
47
- "reset-form": ALLOW_ALL,
48
- javascript: ALLOW_ALL,
49
- uri: {
50
- user: "adapter",
51
- hover: "report",
52
- lifecycle: "report"
53
- },
54
- print: {
55
- user: "adapter",
56
- hover: "block",
57
- lifecycle: "block"
58
- },
59
- "submit-form": {
60
- user: "adapter",
61
- hover: "block",
62
- lifecycle: "block"
63
- }
64
- };
287
+ //#region src/services/policy.ts
65
288
  /** Never executable, not configurable — diagnostics only. */
66
289
  const NEVER_TYPES = /* @__PURE__ */ new Set([
67
290
  "launch",
@@ -71,7 +294,7 @@ const NEVER_TYPES = /* @__PURE__ */ new Set([
71
294
  "movie",
72
295
  "import-data"
73
296
  ]);
74
- /** Recognized types with no Phase-1 interpreter. */
297
+ /** Recognized types with no interpreter: reported, never executed. */
75
298
  const UNSUPPORTED_TYPES = /* @__PURE__ */ new Set([
76
299
  "rendition",
77
300
  "thread",
@@ -82,565 +305,146 @@ const UNSUPPORTED_TYPES = /* @__PURE__ */ new Set([
82
305
  ]);
83
306
  /** Document-lifetime node types: engine mutations, committed in walk order. */
84
307
  const DOCUMENT_TYPES = /* @__PURE__ */ new Set(["reset-form", "javascript"]);
85
- const sameRef = (left, right) => {
86
- if (left.kind === "objectNumber" && right.kind === "objectNumber") return left.annotObjectNumber === right.annotObjectNumber;
87
- if (left.kind === "nm" && right.kind === "nm") return left.pageObjectNumber === right.pageObjectNumber && left.nm === right.nm;
88
- if (left.kind === "index" && right.kind === "index") return left.pageObjectNumber === right.pageObjectNumber && left.index === right.index;
89
- return false;
90
- };
91
308
  const isPrintVerb = (node) => node.type === "named" && node.name === "Print";
92
- function createActionsCapability(ctx, config = {}) {
93
- const policy = {
94
- ...DEFAULT_POLICY,
95
- ...config.policy
96
- };
97
- const enqueue = createSerialQueue();
98
- const executors = /* @__PURE__ */ new Map();
99
- let annotCommitSink = null;
100
- let formCommitSink = null;
101
- let uiAdapter = null;
102
- /** Sink 1 of the submit chain (consent = installation). */
103
- let submitHandler = null;
104
- /** The form plugin's dataset resolver (D7's one door). */
105
- let submitResolver = null;
106
- /** D3's document-print latch: while a print event wrapper is active, a
107
- * nested print request is SUPPRESSED (`reentrant-print`) — the adapter
108
- * opens exactly one dialog per outer request. */
109
- let docPrintEventActive = false;
110
- /** D11's deterministic aggregate: JS nodes run in the CURRENT dispatch. */
111
- let jsNodesThisDispatch = 0;
112
- const actionHook = createEventHook((error) => globalThis.console?.error("[actions] onAction observer failed:", error));
113
- const diagnosticHook = createEventHook((error) => globalThis.console?.error("[actions] onDiagnostic observer failed:", error));
114
- const scriptDiagnosticHook = createEventHook((error) => globalThis.console?.error("[actions] onScriptDiagnostic observer failed:", error));
115
- const scriptErrorHook = createEventHook((error) => globalThis.console?.error("[actions] onScriptError observer failed:", error));
116
- ctx.cleanup(() => {
117
- actionHook.dispose();
118
- diagnosticHook.dispose();
119
- scriptDiagnosticHook.dispose();
120
- scriptErrorHook.dispose();
121
- });
122
- /** Policy lookup — `null` means "recognized, no interpreter" (inert). */
309
+ function createPolicy(ctx) {
310
+ const settings = ctx.settings();
311
+ /** The live policy: read at decision time, never captured. */
312
+ const current = () => settings.get().policy;
313
+ /** The decision for one node and origin; `'never'` and `'unsupported'` are fixed, not configurable. */
123
314
  const decisionFor = (node, origin) => {
124
315
  if (NEVER_TYPES.has(node.type)) return "never";
125
316
  if (UNSUPPORTED_TYPES.has(node.type)) return "unsupported";
317
+ const policy = current();
126
318
  if (isPrintVerb(node)) return policy.print[origin];
127
319
  const row = policy[node.type];
128
320
  return row ? row[origin] : "unsupported";
129
321
  };
130
- const allowsPrint = () => ctx.tryGet(DocumentsToken)?.allows("doc.print", ctx.documentId ?? void 0) ?? true;
131
- let actionsSnapshotPromise = null;
132
- const readDocumentActions = () => {
133
- if (!actionsSnapshotPromise) {
134
- const doc = ctx.doc;
135
- const memo = (doc?.actions ? Promise.resolve(doc.actions.read()) : Promise.resolve(null)).catch((error) => {
136
- if (actionsSnapshotPromise === memo) actionsSnapshotPromise = null;
137
- throw error;
138
- });
139
- actionsSnapshotPromise = memo;
140
- }
141
- return actionsSnapshotPromise;
322
+ return {
323
+ decisionFor,
324
+ current
142
325
  };
143
- /** The Table-200 key for each verb-shaped document trigger event. */
144
- const DOC_EVENT_TREES = {
145
- "will-save": "willSave",
146
- "did-save": "didSave",
147
- "will-print": "willPrint",
148
- "did-print": "didPrint",
149
- "will-close": "willClose"
326
+ }
327
+ //#endregion
328
+ //#region src/submit/intent.ts
329
+ const intentOfPayload = (payload) => ({
330
+ url: payload.url,
331
+ fields: payload.fields,
332
+ exclude: payload.flags.exclude,
333
+ includeNoValueFields: payload.flags.includeNoValueFields,
334
+ format: payload.flags.format,
335
+ method: payload.flags.method,
336
+ flagsRaw: payload.flags.raw,
337
+ ...payload.charSet === void 0 ? {} : { charSet: payload.charSet }
338
+ });
339
+ const toFormSubmissionRequest = (request, sentAt) => ({
340
+ entries: request.entries,
341
+ intent: {
342
+ url: request.url,
343
+ format: request.format,
344
+ method: request.method,
345
+ flagsRaw: request.flagsRaw,
346
+ ...request.charSet === void 0 ? {} : { charSet: request.charSet }
347
+ },
348
+ origin: request.origin,
349
+ sentAt
350
+ });
351
+ /** Script `doc.submitForm(...)` → the one normalized intent. Script field
352
+ * names are include-mode by definition (Acrobat's aFields). */
353
+ const intentOfSubmitEffect = (effect) => {
354
+ const format = effect.format ?? "fdf";
355
+ return {
356
+ url: effect.url,
357
+ fields: effect.fieldNames?.map((name) => ({
358
+ kind: "name",
359
+ name
360
+ })) ?? null,
361
+ exclude: false,
362
+ includeNoValueFields: effect.includeEmpty,
363
+ format,
364
+ method: effect.method ?? "post",
365
+ flagsRaw: 0
150
366
  };
151
- const nowMs = () => js?.now?.() ?? Date.now();
152
- const intentOfPayload = (payload) => ({
153
- url: payload.url,
154
- fields: payload.fields,
155
- exclude: payload.flags.exclude,
156
- includeNoValueFields: payload.flags.includeNoValueFields,
157
- format: payload.flags.format,
158
- method: payload.flags.method,
159
- flagsRaw: payload.flags.raw,
160
- ...payload.charSet === void 0 ? {} : { charSet: payload.charSet }
161
- });
162
- const toFormSubmissionRequest = (request) => ({
163
- entries: request.entries,
164
- intent: {
165
- url: request.url,
166
- format: request.format,
167
- method: request.method,
168
- flagsRaw: request.flagsRaw,
169
- ...request.charSet === void 0 ? {} : { charSet: request.charSet }
170
- },
171
- origin: request.origin,
172
- clientTimeMs: nowMs()
173
- });
174
- /**
175
- * The ONE submit door — both sources land here after policy. Sink order:
176
- * embedder handler (explicit beats ambient; detached-promise contract) →
177
- * the document's home (`doc.forms.submit`, engine-asserted, AWAITED — a
178
- * real op with a real receipt) → blocked with `no-submit-sink`. Never a
179
- * network call from this plugin.
180
- */
181
- const performSubmit = async (intent, actionCtx, diagnose) => {
182
- const decision = policy["submit-form"][actionCtx.origin];
183
- if (decision !== "adapter" && decision !== "allow") {
184
- diagnose({
185
- code: "blocked",
186
- message: `submit-form: policy '${decision}' for origin '${actionCtx.origin}'`
187
- });
188
- return { status: "blocked" };
189
- }
190
- if (!submitResolver) {
191
- diagnose({
192
- code: "no-submit-resolver",
193
- message: "submit-form: no dataset resolver registered (form plugin missing?)"
367
+ };
368
+ //#endregion
369
+ //#region src/dispatch/run.ts
370
+ function createRunner(ctx, services, { performSubmit }, { firePrintThroughAdapter }) {
371
+ const { decisionFor } = services.policy;
372
+ const ports = services.ports.slots;
373
+ const { reportDiagnostic } = services.events;
374
+ const { form } = services;
375
+ async function run(tree, actionContext, signal) {
376
+ const diagnostics = [];
377
+ /** Record a diagnostic in the result and report it, with the action it concerns. */
378
+ const diagnose = (diagnostic, action) => {
379
+ diagnostics.push(diagnostic);
380
+ reportDiagnostic(diagnostic, {
381
+ action,
382
+ source: actionContext.source
194
383
  });
195
- return {
196
- status: "blocked",
197
- detail: "no dataset resolver"
198
- };
199
- }
200
- let request;
201
- try {
202
- request = await submitResolver(intent, actionCtx, diagnose);
203
- } catch (error) {
204
- const detail = error instanceof Error ? error.message : String(error);
384
+ };
385
+ if (tree.incomplete) {
205
386
  diagnose({
206
- code: "executor-failed",
207
- message: `submit-form: dataset resolution: ${detail}`
208
- });
387
+ code: "incomplete-tree",
388
+ message: "refused: the action tree is incomplete"
389
+ }, tree.root?.type ?? null);
209
390
  return {
210
- status: "failed",
211
- detail
391
+ status: "refused",
392
+ nodes: [],
393
+ diagnostics
212
394
  };
213
395
  }
214
- const doc = ctx.doc;
215
- const homeSubmit = doc && typeof doc.forms.submit === "function" ? () => Promise.resolve(doc.forms.submit(toFormSubmissionRequest(request))) : null;
216
- if (submitHandler) try {
217
- const outcome = submitHandler(request, { submitToDocumentHome: homeSubmit });
218
- if (outcome && typeof outcome.then === "function") outcome.catch((error) => {
219
- diagnosticHook.emit({
396
+ if (!tree.root) return {
397
+ status: "inert",
398
+ nodes: [],
399
+ diagnostics
400
+ };
401
+ const nodes = [];
402
+ const deferred = [];
403
+ let documentFailed = false;
404
+ let cancelled = false;
405
+ const settle = (result, outcome) => {
406
+ if (outcome.status === "executed") result.status = "executed";
407
+ else if (outcome.status === "inert") {
408
+ result.status = "inert";
409
+ result.detail = outcome.reason;
410
+ diagnose({
411
+ code: "executor-inert",
412
+ message: `${result.type}: ${outcome.reason}`
413
+ }, result.type);
414
+ } else {
415
+ result.status = "failed";
416
+ result.detail = outcome.error;
417
+ diagnose({
220
418
  code: "executor-failed",
221
- message: `submit handler rejected (detached): ${error instanceof Error ? error.message : String(error)}`
222
- });
223
- });
224
- return { status: "executed" };
225
- } catch (error) {
226
- const detail = error instanceof Error ? error.message : String(error);
227
- diagnose({
228
- code: "executor-failed",
229
- message: `submit handler threw: ${detail}`
230
- });
231
- return {
232
- status: "failed",
233
- detail
234
- };
235
- }
236
- if (homeSubmit) try {
237
- await homeSubmit();
238
- return { status: "executed" };
239
- } catch (error) {
240
- const detail = error instanceof Error ? error.message : String(error);
241
- diagnose({
242
- code: "executor-failed",
243
- message: `document home refused submit: ${detail}`
244
- });
245
- return {
246
- status: "failed",
247
- detail
248
- };
249
- }
250
- diagnose({
251
- code: "no-submit-sink",
252
- message: "submit-form: no handler installed and the document has no submit-capable home"
253
- });
254
- return {
255
- status: "blocked",
256
- detail: "no submit sink"
257
- };
258
- };
259
- const js = config.javascript;
260
- const realms = js?.enabled && ctx.doc ? createScriptRealmFactory(js, ctx.doc) : null;
261
- const scriptHost = realms && ctx.doc ? realms.realmFor({
262
- doc: ctx.doc,
263
- document: () => ctx.document(),
264
- bootSources: async () => {
265
- return ((await readDocumentActions())?.nameTreeScripts ?? []).map(({ action }) => javaScriptProgramFromActionTree(action));
266
- }
267
- }) : null;
268
- if (scriptHost) ctx.cleanup(() => scriptHost.dispose());
269
- const surfaceScriptResult = (result) => {
270
- const uiContext = {
271
- origin: result.origin,
272
- phase: result.phase
273
- };
274
- for (const effect of result.uiEffects) {
275
- if (result.realm === "detached" && effect.kind !== "alert") {
276
- scriptDiagnosticHook.emit({
277
- code: "ui-effect-suppressed",
278
- message: `script ${effect.kind} request from a detached realm suppressed: no document surface`
279
- });
280
- continue;
281
- }
282
- if (effect.kind === "submitForm") {
283
- performSubmit(intentOfSubmitEffect(effect), {
284
- origin: result.origin,
285
- source: { kind: "api" },
286
- event: { scope: "activate" }
287
- }, (diagnostic) => diagnosticHook.emit(diagnostic));
288
- continue;
289
- }
290
- if (effect.kind === "print" && !allowsPrint()) {
291
- scriptDiagnosticHook.emit({
292
- code: "ui-effect-suppressed",
293
- message: "script print request withheld: doc.print is not allowed"
294
- });
295
- continue;
296
- }
297
- if (effect.kind === "print" && docPrintEventActive) {
298
- scriptDiagnosticHook.emit({
299
- code: "ui-effect-suppressed",
300
- message: "script print request during a document print event — suppressed (reentrant)"
301
- });
302
- continue;
303
- }
304
- if (!uiAdapter) {
305
- diagnosticHook.emit({
306
- code: "no-adapter",
307
- message: `script ${effect.kind}: no UI adapter installed`
308
- });
309
- continue;
310
- }
311
- if (effect.kind === "alert") uiAdapter.alert?.(effect.message, {
312
- ...uiContext,
313
- icon: effect.icon,
314
- ...effect.title !== void 0 ? { title: effect.title } : {}
315
- });
316
- else if (effect.kind === "gotoPage") uiAdapter.gotoPage?.(effect.page, uiContext);
317
- else uiAdapter.print(uiContext);
318
- }
319
- for (const diagnostic of result.diagnostics) scriptDiagnosticHook.emit(diagnostic);
320
- if (result.error) scriptErrorHook.emit(result.error);
321
- };
322
- /** A K/V/C/F commit surfaces as up to two results: the boot phase (only
323
- * when it produced effects) and the user phase (always — it carries the
324
- * diagnostics and the error). */
325
- const surfaceScriptCommit = (commit, context) => {
326
- for (const phase of ["boot", "user"]) {
327
- const uiEffects = commit.uiEffects.filter((effect) => effect.phase === phase);
328
- if (uiEffects.length === 0 && phase === "boot") continue;
329
- surfaceScriptResult({
330
- uiEffects,
331
- diagnostics: phase === "user" ? commit.diagnostics : [],
332
- ...phase === "user" && commit.error ? { error: commit.error } : {},
333
- origin: context.origin,
334
- phase,
335
- realm: context.realm
336
- });
337
- }
338
- };
339
- /** Script `doc.submitForm(...)` → the one normalized intent. Script field
340
- * names are include-mode by definition (Acrobat's aFields). */
341
- const intentOfSubmitEffect = (effect) => {
342
- const format = effect.format ?? "fdf";
343
- return {
344
- url: effect.url,
345
- fields: effect.fieldNames?.map((name) => ({
346
- kind: "name",
347
- name
348
- })) ?? null,
349
- exclude: false,
350
- includeNoValueFields: effect.includeEmpty,
351
- format,
352
- method: effect.method ?? "post",
353
- flagsRaw: 0
354
- };
355
- };
356
- const engineColorToArray = (color) => color ? [
357
- "RGB",
358
- color.r / 255,
359
- color.g / 255,
360
- color.b / 255
361
- ] : ["T"];
362
- const pageIndexOf = (pon) => ctx.document()?.pages.findIndex((page) => page.pageObjectNumber === pon) ?? -1;
363
- const scriptWorldFor = async (pon) => {
364
- const doc = ctx.doc;
365
- const snapshot = await doc.forms.list();
366
- const pageIndex = Math.max(0, pageIndexOf(pon));
367
- const { annotations } = await doc.page(pon).annotations.list();
368
- const annots = annotations.filter((a) => a.subtype !== "link" && !a.subtype.startsWith("widget")).map((a) => {
369
- const styled = a;
370
- return {
371
- ref: a.ref,
372
- name: a.nm ?? "",
373
- subtype: a.subtype,
374
- page: pageIndex,
375
- rect: [
376
- a.rect.left,
377
- a.rect.bottom,
378
- a.rect.right,
379
- a.rect.top
380
- ],
381
- contents: a.contents ?? "",
382
- author: a.author ?? "",
383
- subject: a.subject ?? "",
384
- strokeColor: engineColorToArray(styled.color),
385
- fillColor: engineColorToArray(styled.interiorColor),
386
- opacity: styled.opacity ?? 1,
387
- width: styled.strokeWidth ?? 1,
388
- borderStyle: styled.borderStyle === "dashed" ? "D" : "S",
389
- dash: styled.dashArray ?? [],
390
- hidden: a.flags.hidden,
391
- print: a.flags.print,
392
- readOnly: a.flags.readOnly,
393
- locked: a.flags.locked,
394
- noView: a.flags.noView,
395
- toggleNoView: a.flags.toggleNoView,
396
- opaqueBody: a.subtype === "stamp"
397
- };
398
- });
399
- return {
400
- snapshot,
401
- world: {
402
- fields: scriptFieldsFromSnapshot(snapshot),
403
- annots,
404
- annotPages: [pageIndex],
405
- annotsCoverDocument: (ctx.document()?.pageCount ?? 0) <= 1
406
- }
407
- };
408
- };
409
- const ANNOTATION_EVENT_NAMES = {
410
- cursorEnter: "Mouse Enter",
411
- cursorExit: "Mouse Exit",
412
- mouseDown: "Mouse Down",
413
- mouseUp: "Mouse Up",
414
- focus: "Focus",
415
- blur: "Blur"
416
- };
417
- const PAGE_EVENT_NAMES = {
418
- open: "Open",
419
- close: "Close",
420
- visible: "Visible",
421
- invisible: "Invisible"
422
- };
423
- const DOC_EVENT_NAMES = {
424
- open: "Open",
425
- "will-save": "WillSave",
426
- "did-save": "DidSave",
427
- "will-print": "WillPrint",
428
- "did-print": "DidPrint",
429
- "will-close": "WillClose"
430
- };
431
- const scriptEventFor = (actionCtx, snapshot) => {
432
- const provenance = actionCtx.event;
433
- const type = provenance.scope === "page" ? "Page" : provenance.scope === "document" ? "Doc" : actionCtx.source.kind === "widget" ? "Field" : actionCtx.source.kind === "link" ? "Link" : "Annot";
434
- const name = provenance.scope === "activate" ? "Mouse Up" : provenance.scope === "annotation" ? ANNOTATION_EVENT_NAMES[provenance.name] ?? "Mouse Up" : provenance.scope === "page" ? PAGE_EVENT_NAMES[provenance.name] ?? "Open" : DOC_EVENT_NAMES[provenance.name] ?? "Open";
435
- const targetRef = actionCtx.source.kind === "widget" ? actionCtx.source.field : void 0;
436
- const targetField = targetRef ? snapshot.fields.find((field) => targetRef.kind === "objectNumber" && field.ref.kind === "objectNumber" ? field.ref.fieldObjectNumber === targetRef.fieldObjectNumber : targetRef.kind === "fqn" && field.name === targetRef.name) : void 0;
437
- return {
438
- kind: "widget-activate",
439
- type,
440
- name,
441
- ...targetRef ? {
442
- target: targetRef,
443
- source: targetRef
444
- } : {},
445
- ...targetField && targetField.valueEntry.kind === "scalar" ? { value: targetField.valueEntry.value } : {}
446
- };
447
- };
448
- /** Commit one run's document effects through the owner sinks in the
449
- * DECLARED order (form first, then annot); the first failure skips the
450
- * rest across BOTH streams. Returns the failure summary, if any. */
451
- const commitScriptOutput = async (output, diagnose) => {
452
- let failure = null;
453
- if (output.formEffects.length > 0) if (!formCommitSink) diagnose({
454
- code: "no-commit-sink",
455
- message: `script form effects dropped: no form commit sink registered (${output.formEffects.length})`
456
- });
457
- else {
458
- const bad = (await formCommitSink(output.formEffects)).results.find((entry) => entry.status === "failed" || entry.status === "rejected");
459
- if (bad) failure = `form effect ${bad.index}: ${bad.error?.message ?? bad.status}`;
460
- }
461
- if (output.annotEffects.length > 0) if (failure) diagnose({
462
- code: "executor-failed",
463
- message: `script annot effects skipped after form failure (${output.annotEffects.length})`
464
- });
465
- else if (!annotCommitSink) diagnose({
466
- code: "no-commit-sink",
467
- message: `script annot effects dropped: no annotation commit sink registered (${output.annotEffects.length})`
468
- });
469
- else {
470
- const entries = output.annotEffects.map((effect) => ({
471
- annotObjectNumber: effect.ref.kind === "objectNumber" ? effect.ref.annotObjectNumber : -1,
472
- ...effect.ref.kind === "objectNumber" ? { pageObjectNumber: effect.ref.pageObjectNumber } : {},
473
- patch: effect.patch
474
- }));
475
- const bad = (await annotCommitSink(entries)).results.find((entry) => entry.status === "failed");
476
- if (bad) failure = `annotation ${bad.annotObjectNumber}: ${bad.error ?? "failed"}`;
477
- }
478
- if (failure) diagnose({
479
- code: "executor-failed",
480
- message: `script commit: ${failure}`
481
- });
482
- return failure;
483
- };
484
- if (scriptHost) {
485
- const nodeCap = js?.maxScriptNodesPerDispatch ?? 16;
486
- executors.set("javascript", async (node, actionCtx) => {
487
- if (node.type !== "javascript") return {
488
- status: "inert",
489
- reason: "not a JS node"
490
- };
491
- if (!ctx.doc) return {
492
- status: "inert",
493
- reason: "no document"
494
- };
495
- if (jsNodesThisDispatch >= nodeCap) return {
496
- status: "inert",
497
- reason: `dispatch script budget exhausted (${nodeCap} JS nodes)`
498
- };
499
- jsNodesThisDispatch += 1;
500
- const source = actionCtx.source;
501
- const pon = source.kind === "widget" ? source.pon : source.kind === "link" || source.kind === "annotation" ? source.pon ?? ctx.document()?.pages[0]?.pageObjectNumber : source.kind === "page" ? source.pon : ctx.document()?.pages[0]?.pageObjectNumber;
502
- if (pon === void 0) return {
503
- status: "inert",
504
- reason: "no page to anchor the world on"
505
- };
506
- const diagnose = (diagnostic) => diagnosticHook.emit(diagnostic);
507
- const pendingExternal = [];
508
- const splitExternal = (output, phase) => output.uiEffects.filter((effect) => {
509
- if (effect.kind === "print" || effect.kind === "submitForm") {
510
- pendingExternal.push({
511
- effect,
512
- phase
513
- });
514
- return false;
515
- }
516
- return true;
517
- });
518
- const outcome = await scriptHost.transaction(async (txn) => {
519
- let built = await scriptWorldFor(pon);
520
- const boot = await txn.boot({
521
- ...built.world,
522
- event: {
523
- kind: "name-tree-boot",
524
- type: "Doc",
525
- name: "Open"
526
- }
527
- });
528
- if (boot) {
529
- await commitScriptOutput(boot, diagnose);
530
- surfaceScriptResult({
531
- uiEffects: splitExternal(boot, "boot"),
532
- diagnostics: boot.diagnostics,
533
- ...boot.error ? { error: boot.error } : {},
534
- origin: actionCtx.origin,
535
- phase: "boot",
536
- realm: "document"
537
- });
538
- if (boot.formEffects.length || boot.annotEffects.length) built = await scriptWorldFor(pon);
539
- }
540
- const output = await txn.run(node.script, {
541
- ...built.world,
542
- event: scriptEventFor(actionCtx, built.snapshot)
543
- });
544
- surfaceScriptResult({
545
- uiEffects: splitExternal(output, "user"),
546
- diagnostics: output.diagnostics,
547
- ...output.error ? { error: output.error } : {},
548
- origin: actionCtx.origin,
549
- phase: "user",
550
- realm: "document"
551
- });
552
- if (output.error) return {
553
- status: "failed",
554
- error: output.error.message
555
- };
556
- const failure = await commitScriptOutput(output, diagnose);
557
- return failure ? {
558
- status: "failed",
559
- error: failure
560
- } : { status: "executed" };
561
- });
562
- for (const entry of pendingExternal) if (entry.effect.kind === "print") await firePrintThroughAdapter({
563
- origin: actionCtx.origin,
564
- phase: entry.phase
565
- }, diagnose);
566
- else await performSubmit(intentOfSubmitEffect(entry.effect), actionCtx, diagnose);
567
- return outcome;
568
- });
569
- }
570
- async function run(tree, actionCtx) {
571
- const diagnostics = [];
572
- const diagnose = (diagnostic) => {
573
- diagnostics.push(diagnostic);
574
- diagnosticHook.emit(diagnostic);
575
- };
576
- if (tree.incomplete) {
577
- diagnose({
578
- code: "incomplete-tree",
579
- message: "refused: the action tree is incomplete"
580
- });
581
- return {
582
- status: "refused",
583
- nodes: [],
584
- diagnostics
585
- };
586
- }
587
- if (!tree.root) return {
588
- status: "inert",
589
- nodes: [],
590
- diagnostics
591
- };
592
- const nodes = [];
593
- const deferred = [];
594
- let documentFailed = false;
595
- const settle = (result, outcome) => {
596
- if (outcome.status === "executed") result.status = "executed";
597
- else if (outcome.status === "inert") {
598
- result.status = "inert";
599
- result.detail = outcome.reason;
600
- diagnose({
601
- code: "executor-inert",
602
- message: `${result.type}: ${outcome.reason}`
603
- });
604
- } else {
605
- result.status = "failed";
606
- result.detail = outcome.error;
607
- diagnose({
608
- code: "executor-failed",
609
- message: `${result.type}: ${outcome.error}`
610
- });
611
- }
419
+ message: `${result.type}: ${outcome.error}`
420
+ }, result.type);
421
+ }
612
422
  };
613
423
  const interpretHide = async (node) => {
614
- const doc = ctx.doc;
615
424
  const display = node.hide ? "hidden" : "visible";
425
+ const diagnoseHide = (diagnostic) => diagnose(diagnostic, "hide");
616
426
  const fieldRefs = [];
617
427
  const annotEntries = [];
618
428
  const names = [];
619
429
  const bareObjectNumbers = [];
620
430
  for (const target of node.targets) if (target.kind === "objectNumber") bareObjectNumbers.push(target.objectNumber);
621
431
  else names.push(target.name);
622
- if ((names.length || bareObjectNumbers.length) && doc) {
623
- const snapshot = names.length || bareObjectNumbers.length ? await doc.forms.list() : null;
432
+ if (names.length || bareObjectNumbers.length) {
433
+ const snapshot = await form.read();
624
434
  for (const name of names) {
625
- const field = snapshot?.fields.find((candidate) => candidate.name === name);
435
+ const field = snapshot.fields.find((candidate) => candidate.name === name);
626
436
  if (!field) {
627
- diagnose({
437
+ diagnoseHide({
628
438
  code: "unresolved-target",
629
439
  message: `hide: no field named '${name}'`
630
440
  });
631
441
  continue;
632
442
  }
633
- fieldRefs.push({
634
- kind: "objectNumber",
635
- fieldObjectNumber: field.fieldObjectNumber
636
- });
443
+ fieldRefs.push(field.ref);
637
444
  }
638
445
  for (const objectNumber of bareObjectNumbers) {
639
- const owner = snapshot?.fields.find((field) => field.widgets.some((widget) => widget.annotObjectNumber === objectNumber));
640
- if (owner) fieldRefs.push({
641
- kind: "objectNumber",
642
- fieldObjectNumber: owner.fieldObjectNumber
643
- });
446
+ const owner = snapshot.fields.find((field) => field.widgets.some((widget) => widget.objectNumber === objectNumber));
447
+ if (owner) fieldRefs.push(owner.ref);
644
448
  else annotEntries.push({
645
449
  annotObjectNumber: objectNumber,
646
450
  patch: { flags: { hidden: node.hide } }
@@ -649,37 +453,37 @@ function createActionsCapability(ctx, config = {}) {
649
453
  }
650
454
  let failedDetail;
651
455
  if (fieldRefs.length) {
652
- if (!formCommitSink) {
653
- diagnose({
456
+ if (!ports.formCommitSink) {
457
+ diagnoseHide({
654
458
  code: "no-commit-sink",
655
459
  message: "hide: no form commit sink registered (form plugin absent)"
656
460
  });
657
461
  return { status: "no-executor" };
658
462
  }
659
- const bad = (await formCommitSink(fieldRefs.map((ref) => ({
463
+ const result = await ports.formCommitSink(fieldRefs.map((ref) => ({
660
464
  kind: "setDisplay",
661
465
  ref,
662
466
  display
663
- })))).results.find((entry) => entry.status === "failed" || entry.status === "rejected");
664
- if (bad) {
665
- failedDetail = bad.error?.message ?? bad.status;
666
- diagnose({
467
+ })));
468
+ if (result.status === "failed") {
469
+ failedDetail = result.error ?? "failed";
470
+ diagnoseHide({
667
471
  code: "executor-failed",
668
472
  message: `hide: ${failedDetail}`
669
473
  });
670
474
  }
671
475
  }
672
476
  if (annotEntries.length && !failedDetail) {
673
- if (!annotCommitSink) {
674
- diagnose({
477
+ if (!ports.annotCommitSink) {
478
+ diagnoseHide({
675
479
  code: "no-commit-sink",
676
480
  message: "hide: no annotation commit sink registered (annotation plugin absent)"
677
481
  });
678
482
  return { status: "no-executor" };
679
483
  }
680
- const committed = await annotCommitSink(annotEntries);
484
+ const committed = await ports.annotCommitSink(annotEntries);
681
485
  const failed = committed.results.filter((entry) => entry.status === "failed");
682
- for (const failure of failed) diagnose({
486
+ for (const failure of failed) diagnoseHide({
683
487
  code: "executor-failed",
684
488
  message: `hide: annotation ${failure.annotObjectNumber}: ${failure.error ?? "failed"}`
685
489
  });
@@ -698,12 +502,13 @@ function createActionsCapability(ctx, config = {}) {
698
502
  status: "blocked"
699
503
  };
700
504
  nodes.push(result);
701
- const decision = decisionFor(node, actionCtx.origin);
505
+ const decision = decisionFor(node, actionContext.origin);
506
+ const diagnoseNode = (diagnostic) => diagnose(diagnostic, node.type);
702
507
  if (decision === "never" || decision === "block" || decision === "report") {
703
508
  result.status = "blocked";
704
- diagnose({
509
+ if (decision !== "block") diagnoseNode({
705
510
  code: "blocked",
706
- message: `${node.type}: ${decision === "never" ? "never executable" : `policy '${decision}' for origin '${actionCtx.origin}'`}`
511
+ message: `${node.type}: ${decision === "never" ? "never executable" : `policy 'report' for origin '${actionContext.origin}'`}`
707
512
  });
708
513
  return;
709
514
  }
@@ -727,17 +532,17 @@ function createActionsCapability(ctx, config = {}) {
727
532
  result.status = "skipped";
728
533
  return;
729
534
  }
730
- const executor = executors.get(node.type);
535
+ const executor = ports.executors.get(node.type);
731
536
  if (!executor) {
732
537
  result.status = node.type === "javascript" ? "inert" : "no-executor";
733
- diagnose({
538
+ diagnoseNode({
734
539
  code: "no-executor",
735
540
  message: `${node.type}: no executor registered${node.type === "javascript" ? " (scripting unavailable)" : ""}`
736
541
  });
737
542
  return;
738
543
  }
739
544
  try {
740
- settle(result, await executor(node, actionCtx));
545
+ settle(result, await executor(node, actionContext));
741
546
  } catch (error) {
742
547
  settle(result, {
743
548
  status: "failed",
@@ -751,7 +556,7 @@ function createActionsCapability(ctx, config = {}) {
751
556
  if (!node.payload) {
752
557
  result.status = "inert";
753
558
  result.detail = "submit payload unavailable (older runtime extraction)";
754
- diagnose({
559
+ diagnoseNode({
755
560
  code: "submit-payload-unavailable",
756
561
  message: "submit-form: payload unavailable (older runtime extraction) — node inert"
757
562
  });
@@ -762,7 +567,7 @@ function createActionsCapability(ctx, config = {}) {
762
567
  deferred.push({
763
568
  result,
764
569
  fire: async () => {
765
- const outcome = await performSubmit(intentOfPayload(payload), actionCtx, diagnose);
570
+ const outcome = await performSubmit(intentOfPayload(payload), actionContext, diagnoseNode);
766
571
  result.status = outcome.status;
767
572
  if (outcome.detail) result.detail = outcome.detail;
768
573
  }
@@ -775,22 +580,22 @@ function createActionsCapability(ctx, config = {}) {
775
580
  result,
776
581
  fire: async () => {
777
582
  if (node.type === "uri") {
778
- if (!uiAdapter) {
583
+ if (!ports.uiAdapter) {
779
584
  result.status = "no-executor";
780
- diagnose({
585
+ diagnoseNode({
781
586
  code: "no-adapter",
782
587
  message: `${node.type}: no UI adapter installed`
783
588
  });
784
589
  return;
785
590
  }
786
- uiAdapter.openUri(node.uri, {
591
+ ports.uiAdapter.openUri(node.uri, {
787
592
  isMap: node.isMap,
788
- origin: actionCtx.origin
593
+ origin: actionContext.origin
789
594
  });
790
595
  result.status = "executed";
791
596
  return;
792
597
  }
793
- const outcome = await firePrintThroughAdapter(void 0, diagnose);
598
+ const outcome = await firePrintThroughAdapter(void 0, diagnoseNode);
794
599
  result.status = outcome.status;
795
600
  if (outcome.detail) result.detail = outcome.detail;
796
601
  }
@@ -801,17 +606,17 @@ function createActionsCapability(ctx, config = {}) {
801
606
  deferred.push({
802
607
  result,
803
608
  fire: async () => {
804
- const executor = executors.get(node.type);
609
+ const executor = ports.executors.get(node.type);
805
610
  if (!executor) {
806
611
  result.status = "no-executor";
807
- diagnose({
612
+ diagnoseNode({
808
613
  code: "no-executor",
809
614
  message: `${node.type}: no executor registered`
810
615
  });
811
616
  return;
812
617
  }
813
618
  try {
814
- settle(result, await executor(node, actionCtx));
619
+ settle(result, await executor(node, actionContext));
815
620
  } catch (error) {
816
621
  settle(result, {
817
622
  status: "failed",
@@ -822,11 +627,20 @@ function createActionsCapability(ctx, config = {}) {
822
627
  });
823
628
  };
824
629
  const walk = async (node, path) => {
630
+ if (signal?.aborted) {
631
+ cancelled = true;
632
+ return;
633
+ }
825
634
  await interpret(node, path);
826
- for (let index = 0; index < node.next.length; index++) await walk(node.next[index], [...path, index]);
635
+ for (let index = 0; index < node.next.length && !cancelled; index++) await walk(node.next[index], [...path, index]);
827
636
  };
828
637
  await walk(tree.root, []);
829
- if (!documentFailed) for (const entry of deferred) await entry.fire();
638
+ if (!documentFailed && !cancelled) for (const entry of deferred) await entry.fire();
639
+ if (cancelled) return {
640
+ status: "cancelled",
641
+ nodes,
642
+ diagnostics
643
+ };
830
644
  const anyExecuted = nodes.some((node) => node.status === "executed");
831
645
  const anyFailedOrSkipped = nodes.some((node) => node.status === "failed" || node.status === "skipped");
832
646
  return {
@@ -835,239 +649,426 @@ function createActionsCapability(ctx, config = {}) {
835
649
  diagnostics
836
650
  };
837
651
  }
838
- /** run + seq + event — the shared per-tree unit (queued by execute; called
839
- * inline per step by the queued trigger resolver). */
840
- const runAndEmit = async (tree, actionCtx) => {
841
- const result = await run(tree, actionCtx);
842
- ctx.dispatch({ type: "ACTIONS_DISPATCHED" });
843
- actionHook.emit({
844
- ctx: actionCtx,
845
- tree,
846
- result
847
- });
848
- return result;
849
- };
850
- const execute = (tree, actionCtx) => {
851
- if (actionCtx.origin === "user") noteUserActivity();
852
- return enqueue(() => {
853
- jsNodesThisDispatch = 0;
854
- return runAndEmit(tree, actionCtx);
855
- });
856
- };
857
- const canExecute = (tree, actionCtx) => {
858
- if (tree.incomplete || !tree.root) return false;
859
- const decision = decisionFor(tree.root, actionCtx.origin);
860
- return decision === "allow" || decision === "adapter";
861
- };
652
+ return { run };
653
+ }
654
+ //#endregion
655
+ //#region src/dispatch/triggers.ts
656
+ /**
657
+ * The pages whose annotations an event changes: those pages, every page
658
+ * (`'all'`: a redaction, a skipped stretch of the stream, new document
659
+ * bytes), or none.
660
+ */
661
+ const annotationPagesOf = (event) => {
662
+ switch (event.type) {
663
+ case "annotations.created":
664
+ case "annotations.updated":
665
+ case "annotations.deleted":
666
+ case "annotations.reordered":
667
+ case "annotations.flattened":
668
+ case "forms.widgetsReordered": return [event.page];
669
+ case "forms.created":
670
+ case "forms.deleted":
671
+ case "forms.widgetAdded":
672
+ case "forms.widgetRemoved":
673
+ case "forms.widgetDeleted":
674
+ case "forms.widgetRestored":
675
+ case "forms.widgetUpdated":
676
+ case "forms.restored":
677
+ case "forms.repaired": return "all";
678
+ case "pages.flattened": return event.pages;
679
+ case "redaction.applied":
680
+ case "stream.desynced":
681
+ case "document.versioned": return "all";
682
+ default: return null;
683
+ }
684
+ };
685
+ function createTriggers(ctx, { settings, form }) {
862
686
  const triggerEnabled = (trigger) => {
687
+ const { triggers } = settings.get();
863
688
  switch (trigger.scope) {
864
689
  case "activate": return true;
865
- case "annotation": return config.triggers?.annotation !== false;
866
- case "page": return config.triggers?.page !== false;
867
- case "document": return config.triggers?.document !== false;
690
+ case "annotation": return triggers.annotation;
691
+ case "page": return triggers.page;
692
+ case "document": return triggers.document;
868
693
  }
869
694
  };
870
- const lifecycleCache = /* @__PURE__ */ new Map();
871
- const lifecycleTreesFor = async (pon) => {
872
- const hit = lifecycleCache.get(pon);
873
- if (hit) return hit;
874
- const doc = ctx.doc;
875
- if (!doc) return [];
876
- const { annotations } = await doc.page(pon).annotations.list();
877
- const bearing = annotations.filter((a) => a.actions && (a.actions.pageOpen?.root || a.actions.pageClose?.root || a.actions.pageVisible?.root || a.actions.pageInvisible?.root)).map((a) => ({
878
- ref: a.ref,
879
- actions: a.actions
880
- }));
881
- lifecycleCache.set(pon, bearing);
882
- return bearing;
883
- };
884
- {
885
- const unsubscribe = ctx.doc?.events?.subscribe((event) => {
886
- if (event.type.startsWith("annotation.") || event.type === "stream.desynced") lifecycleCache.clear();
695
+ const lifecycleReads = /* @__PURE__ */ new Map();
696
+ const lifecycleAnnotationsOf = (page) => {
697
+ const key = page.objectNumber;
698
+ const cached = lifecycleReads.get(key);
699
+ if (cached) return cached;
700
+ const read = readPageAnnotations(ctx, form, page).then((annotations) => annotations.filter((annotation) => annotation.actions && (annotation.actions.pageOpen?.root || annotation.actions.pageClose?.root || annotation.actions.pageVisible?.root || annotation.actions.pageInvisible?.root)).map((annotation) => ({
701
+ ref: annotation.ref,
702
+ actions: annotation.actions
703
+ })));
704
+ lifecycleReads.set(key, read);
705
+ read.catch(() => {
706
+ if (lifecycleReads.get(key) === read) lifecycleReads.delete(key);
887
707
  });
888
- if (unsubscribe) ctx.cleanup(unsubscribe);
889
- }
708
+ return read;
709
+ };
710
+ /** Forget the pages a confirmed document event changed. */
711
+ const invalidate = (event) => {
712
+ const pages = annotationPagesOf(event);
713
+ if (pages === "all") lifecycleReads.clear();
714
+ else if (pages) for (const page of pages) lifecycleReads.delete(page.objectNumber);
715
+ };
890
716
  /**
891
- * ISO order, verified against 32000-2 Table 197 (2026-09-02): PO "shall be
892
- * executed after the O action … and the OpenAction entry"; PC "shall be
893
- * executed before the C action". Rootless trees are skipped (a
894
- * budget-degraded tree with no root has nothing to walk).
717
+ * ISO 32000-2 Table 197 order: /PO "shall be executed after the O action …
718
+ * and the OpenAction entry"; /PC "shall be executed before the C action".
719
+ * Rootless trees are skipped (a budget-degraded tree with no root has
720
+ * nothing to walk).
895
721
  */
896
- const planPageSteps = (event, pon, pageActions, lifecycle) => {
722
+ const planPageSteps = (event, page, pageActions, lifecycle) => {
897
723
  const pageSource = {
898
724
  kind: "page",
899
- pon
725
+ page
900
726
  };
901
- const annotSteps = (key) => lifecycle.filter((a) => a.actions[key]?.root).map((a) => ({
727
+ const annotationSteps = (key) => lifecycle.filter((annotation) => annotation.actions[key]?.root).map((annotation) => ({
902
728
  source: {
903
729
  kind: "annotation",
904
- annotation: a.ref,
905
- pon
730
+ annotation: annotation.ref,
731
+ page
906
732
  },
907
- tree: a.actions[key]
733
+ tree: annotation.actions[key]
908
734
  }));
909
735
  switch (event) {
910
736
  case "open": return [...pageActions?.open?.root ? [{
911
737
  source: pageSource,
912
738
  tree: pageActions.open
913
- }] : [], ...annotSteps("pageOpen")];
914
- case "close": return [...annotSteps("pageClose"), ...pageActions?.close?.root ? [{
739
+ }] : [], ...annotationSteps("pageOpen")];
740
+ case "close": return [...annotationSteps("pageClose"), ...pageActions?.close?.root ? [{
915
741
  source: pageSource,
916
742
  tree: pageActions.close
917
743
  }] : []];
918
- case "visible": return annotSteps("pageVisible");
919
- case "invisible": return annotSteps("pageInvisible");
744
+ case "visible": return annotationSteps("pageVisible");
745
+ case "invisible": return annotationSteps("pageInvisible");
920
746
  }
921
747
  };
922
- /** Aggregate step statuses. A step failure never skips its siblings; this
923
- * only FOLDS what each step reported. */
924
- const foldSteps = (steps, diagnostics) => {
925
- const statuses = steps.map((s) => s.result.status);
926
- return {
927
- status: steps.length === 0 ? "inert" : statuses.every((s) => s === "refused") ? "refused" : statuses.every((s) => s === "inert") ? "inert" : statuses.every((s) => s === "executed") ? "executed" : "partial",
928
- steps,
929
- diagnostics
930
- };
931
- };
932
- const runSteps = async (steps, origin, event, diagnostics) => {
933
- const results = [];
934
- for (const step of steps) {
935
- const result = await runAndEmit(step.tree, {
936
- origin,
937
- source: step.source,
938
- event
939
- });
940
- results.push({
941
- source: step.source,
942
- tree: step.tree,
943
- result
944
- });
945
- }
946
- return foldSteps(results, diagnostics);
748
+ return {
749
+ triggerEnabled,
750
+ lifecycleAnnotationsOf,
751
+ invalidate,
752
+ planPageSteps
947
753
  };
948
- /** Everything a trigger needs — reads included — INSIDE the queued op:
949
- * submission order is execution order. Never throws. */
950
- const resolveAndRun = async (trigger) => {
951
- const diagnostics = [];
952
- const diagnose = (diagnostic) => {
953
- diagnostics.push(diagnostic);
954
- diagnosticHook.emit(diagnostic);
955
- };
754
+ }
755
+ //#endregion
756
+ //#region src/lifecycle/document-events.ts
757
+ /**
758
+ * The verb-shaped document events (/WC, /WS, /DS, /WP, /DP) and the print
759
+ * door: one catalog lifecycle tree per event, run inside the current queue
760
+ * operation and never throwing (a broken script must not cancel the user's
761
+ * save or print), and a print request sent to the kernel's print, or, without
762
+ * a printer, to the adapter, wrapped /WP → print → /DP under the print latch.
763
+ */
764
+ function createDocumentEvents(services, { ensureOpenSequenceBeforeDocEvent }, { runAndEmit }) {
765
+ const { reportDiagnostic } = services.events;
766
+ const { readDocumentActions, DOC_EVENT_TREES } = services.catalog;
767
+ const { enqueue, budget } = services.queue;
768
+ const ports = services.ports.slots;
769
+ const { allowsPrint } = services.authority;
770
+ const { documentPrint } = services;
771
+ const printLatch = services.printLatch;
772
+ /**
773
+ * Run one catalog lifecycle tree inside the current queue operation, shared
774
+ * by the dispatch path, `runDocumentVerb` and the print wrapper. Never
775
+ * throws: a read or resolution failure degrades to a diagnostic, because a
776
+ * broken script must not cancel the user's save or print.
777
+ */
778
+ const runDocEventTreeSafe = async (event, diagnose) => {
779
+ if (!services.settings.get().triggers.document) return null;
956
780
  try {
957
- const doc = ctx.doc;
958
- if (!doc) return {
959
- status: "inert",
960
- steps: [],
961
- diagnostics
781
+ await ensureOpenSequenceBeforeDocEvent();
782
+ const tree = (await readDocumentActions())?.[DOC_EVENT_TREES[event]];
783
+ if (!tree?.root && !tree?.incomplete) return null;
784
+ return {
785
+ source: { kind: "document" },
786
+ tree,
787
+ result: await runAndEmit(tree, {
788
+ origin: "lifecycle",
789
+ source: { kind: "document" },
790
+ event: {
791
+ scope: "document",
792
+ name: event
793
+ }
794
+ })
962
795
  };
963
- if (!triggerEnabled(trigger)) {
796
+ } catch (error) {
797
+ diagnose({
798
+ code: "trigger-failed",
799
+ message: `${event}: ${error instanceof Error ? error.message : String(error)}`
800
+ });
801
+ return null;
802
+ }
803
+ };
804
+ /** The dispatch-path body for a verb-shaped document event. */
805
+ const runDocumentEventOp = async (event, diagnostics, diagnose) => {
806
+ const step = await runDocEventTreeSafe(event, diagnose);
807
+ return foldSteps(step ? [step] : [], diagnostics);
808
+ };
809
+ /**
810
+ * Both print paths (the Named Print verb, and script `doc.print()` effects
811
+ * from the actions plane) go through here. While the latch is held
812
+ * (including `runDocumentVerb('print')`'s body) a nested request is
813
+ * suppressed with `reentrant-print`.
814
+ *
815
+ * With the kernel's print, a user's request starts `documents.print()` and
816
+ * returns: the kernel runs /WP and /DP around the dialog through this
817
+ * plugin's `aroundPrint` wrap, a `runDocumentVerb('print')` that waits for
818
+ * the current queue operation, so it is never awaited here. A hover or
819
+ * lifecycle request never opens the dialog.
820
+ *
821
+ * Without a printer: /WP → `ports.uiAdapter.print` exactly once → /DP,
822
+ * with the latch reset in `finally`. An adapter throw skips /DP (the latch
823
+ * still resets). /DP fires when the adapter call returns, since a browser
824
+ * cannot observe dialog completion.
825
+ */
826
+ const firePrintThroughAdapter = async (uiContext, diagnose) => {
827
+ if (printLatch.active) {
828
+ diagnose({
829
+ code: "reentrant-print",
830
+ message: "print request during a document print event — suppressed (one dialog per request)"
831
+ });
832
+ return {
833
+ status: "blocked",
834
+ detail: "reentrant print suppressed"
835
+ };
836
+ }
837
+ if (!allowsPrint()) {
838
+ diagnose({
839
+ code: "blocked",
840
+ message: "print: doc.print is not allowed"
841
+ });
842
+ return {
843
+ status: "blocked",
844
+ detail: "doc.print is not allowed"
845
+ };
846
+ }
847
+ if (documentPrint.available()) {
848
+ if (uiContext && uiContext.origin !== "user") {
964
849
  diagnose({
965
- code: "trigger-disabled",
966
- message: `${trigger.scope}: trigger family disabled by config`
850
+ code: "blocked",
851
+ message: `print: a ${uiContext.origin} request never prints`
967
852
  });
968
853
  return {
969
- status: "inert",
970
- steps: [],
971
- diagnostics
854
+ status: "blocked",
855
+ detail: `${uiContext.origin} print request`
972
856
  };
973
857
  }
974
- const origin = originOf(trigger);
975
- switch (trigger.scope) {
976
- case "activate":
977
- case "annotation": {
978
- const event = trigger.scope === "activate" ? "activate" : trigger.event;
979
- const { annotations } = await doc.page(trigger.pon).annotations.list();
980
- const annotation = annotations.find((candidate) => sameRef(candidate.ref, trigger.ref));
981
- if (event === "mouseUp" && annotation?.actions?.activate) return {
982
- status: "inert",
983
- steps: [],
984
- diagnostics
985
- };
986
- const tree = annotation?.actions?.[event];
987
- if (!tree?.root && !tree?.incomplete) return {
988
- status: "inert",
989
- steps: [],
990
- diagnostics
991
- };
992
- const source = trigger.source ?? {
993
- kind: "annotation",
994
- annotation: trigger.ref,
995
- pon: trigger.pon
996
- };
997
- return await runSteps([{
998
- source,
999
- tree
1000
- }], origin, eventOf(trigger), diagnostics);
1001
- }
1002
- case "page": {
1003
- const layout = ctx.document()?.pages.find((page) => page.pageObjectNumber === trigger.pon);
1004
- const lifecycle = await lifecycleTreesFor(trigger.pon);
1005
- const steps = planPageSteps(trigger.event, trigger.pon, layout?.actions, lifecycle);
1006
- return await runSteps(steps, origin, eventOf(trigger), diagnostics);
1007
- }
1008
- case "document":
1009
- if (trigger.event !== "open") return await runDocumentEventOp(trigger.event, diagnostics, diagnose);
1010
- if (openFired) {
1011
- diagnose({
1012
- code: "open-sequence-replayed",
1013
- message: "document open sequence already fired for this document"
1014
- });
1015
- return {
1016
- status: "inert",
1017
- steps: [],
1018
- diagnostics
1019
- };
1020
- }
1021
- openFired = true;
1022
- return await runOpenSequenceOp();
1023
- }
1024
- } catch (error) {
1025
- diagnose({
858
+ documentPrint.start().catch((error) => diagnose({
1026
859
  code: "trigger-failed",
1027
- message: `trigger resolution failed: ${error instanceof Error ? error.message : String(error)}`
860
+ message: `print: ${error instanceof Error ? error.message : String(error)}`
861
+ }));
862
+ return { status: "executed" };
863
+ }
864
+ if (!ports.uiAdapter) {
865
+ diagnose({
866
+ code: "no-adapter",
867
+ message: "print: no UI adapter installed"
1028
868
  });
1029
869
  return {
1030
- status: "refused",
1031
- steps: [],
1032
- diagnostics
870
+ status: "no-executor",
871
+ detail: "no UI adapter installed"
1033
872
  };
1034
873
  }
874
+ printLatch.active = true;
875
+ try {
876
+ await runDocEventTreeSafe("will-print", diagnose);
877
+ const adapter = ports.uiAdapter;
878
+ if (!adapter) return {
879
+ status: "no-executor",
880
+ detail: "UI adapter uninstalled mid-print"
881
+ };
882
+ if (uiContext) adapter.print(uiContext);
883
+ else adapter.print();
884
+ await runDocEventTreeSafe("did-print", diagnose);
885
+ return { status: "executed" };
886
+ } finally {
887
+ printLatch.active = false;
888
+ }
1035
889
  };
1036
- const dispatch = (trigger) => {
1037
- if (originOf(trigger) === "user") noteUserActivity();
1038
- return enqueue(() => {
1039
- jsNodesThisDispatch = 0;
1040
- return resolveAndRun(trigger);
1041
- });
890
+ return {
891
+ runDocumentEventOp,
892
+ firePrintThroughAdapter,
893
+ api: { runDocumentVerb: (verb, operation, options) => enqueue(async () => {
894
+ budget.scriptNodes = 0;
895
+ const diagnose = (diagnostic) => reportDiagnostic(diagnostic, { source: { kind: "document" } });
896
+ const before = verb === "save" ? "will-save" : "will-print";
897
+ const after = verb === "save" ? "did-save" : "did-print";
898
+ const body = async () => {
899
+ await runDocEventTreeSafe(before, diagnose);
900
+ if (options?.signal?.aborted) throw new PluginError("operation-cancelled", "actions", `${verb} was cancelled`, { cause: options.signal.reason });
901
+ const value = await operation();
902
+ await runDocEventTreeSafe(after, diagnose);
903
+ return value;
904
+ };
905
+ if (verb !== "print") return body();
906
+ printLatch.active = true;
907
+ try {
908
+ return await body();
909
+ } finally {
910
+ printLatch.active = false;
911
+ }
912
+ }, options) }
1042
913
  };
1043
- const CASCADE_CAP = 8;
914
+ }
915
+ //#endregion
916
+ //#region src/lifecycle/open-sequence.ts
917
+ function createOpenSequence(services, { releaseBarrier, resetCascade }, { runAndEmit }) {
918
+ const { reportDiagnostic, openSequenceCompleted } = services.events;
919
+ /** The `openSequence` setting, read when it decides: it applies until the sequence has run. */
920
+ const mode = () => services.settings.get().openSequence;
921
+ const { readDocumentActions } = services.catalog;
922
+ const { enqueue } = services.queue;
923
+ const ports = services.ports.slots;
924
+ let openFired = false;
925
+ let sawUserActivity = false;
926
+ /** The sequence body. Runs inside the queue (through dispatch or the
927
+ * latch's own enqueue); callers guarantee `openFired` was set. */
928
+ const runOpenSequenceOp = async () => {
929
+ const diagnostics = [];
930
+ const steps = [];
931
+ const openContext = {
932
+ origin: "lifecycle",
933
+ source: { kind: "document" },
934
+ event: {
935
+ scope: "document",
936
+ name: "open"
937
+ }
938
+ };
939
+ try {
940
+ const snapshot = await readDocumentActions();
941
+ if (snapshot?.openDestination) {
942
+ const tree = {
943
+ root: {
944
+ type: "goto",
945
+ subtype: "GoTo",
946
+ destination: snapshot.openDestination,
947
+ next: []
948
+ },
949
+ incomplete: false,
950
+ warningFlags: 0,
951
+ warnings: []
952
+ };
953
+ steps.push({
954
+ source: { kind: "document" },
955
+ tree,
956
+ result: await runAndEmit(tree, openContext)
957
+ });
958
+ }
959
+ if (snapshot?.openAction?.root || snapshot?.openAction?.incomplete) steps.push({
960
+ source: { kind: "document" },
961
+ tree: snapshot.openAction,
962
+ result: await runAndEmit(snapshot.openAction, openContext)
963
+ });
964
+ } catch (error) {
965
+ const diagnostic = {
966
+ code: "trigger-failed",
967
+ message: `open sequence failed: ${error instanceof Error ? error.message : String(error)}`
968
+ };
969
+ diagnostics.push(diagnostic);
970
+ reportDiagnostic(diagnostic, { source: { kind: "document" } });
971
+ } finally {
972
+ releaseBarrier(true);
973
+ }
974
+ const result = foldSteps(steps, diagnostics);
975
+ openSequenceCompleted.emit({ result });
976
+ return result;
977
+ };
978
+ /**
979
+ * The catalog /OpenAction never runs after a /WS, /WP or /WC script: before
980
+ * the first verb-shaped document event, an armed but unfired open sequence
981
+ * runs inline (the caller is already inside the queue; `runOpenSequenceOp`
982
+ * never enqueues). It does not wait for the initial page /O: the guarantee
983
+ * is catalog-level.
984
+ */
985
+ const ensureOpenSequenceBeforeDocEvent = async () => {
986
+ if (openFired || mode() === "off") return;
987
+ openFired = true;
988
+ await runOpenSequenceOp();
989
+ };
990
+ const maybeFireOpenSequence = () => {
991
+ if (openFired) return;
992
+ if (mode() === "off") {
993
+ openFired = true;
994
+ releaseBarrier(false);
995
+ return;
996
+ }
997
+ if (!ports.uiAdapter && mode() !== "headless" && !sawUserActivity) return;
998
+ openFired = true;
999
+ enqueue(() => runOpenSequenceOp());
1000
+ };
1001
+ const noteUserActivity = () => {
1002
+ resetCascade();
1003
+ if (!sawUserActivity) {
1004
+ sawUserActivity = true;
1005
+ maybeFireOpenSequence();
1006
+ }
1007
+ };
1008
+ /** Claim the one-shot open for a dispatched document-open trigger: false
1009
+ * when it already fired (the caller reports a replay). */
1010
+ const claim = () => {
1011
+ if (openFired) return false;
1012
+ openFired = true;
1013
+ return true;
1014
+ };
1015
+ return {
1016
+ run: runOpenSequenceOp,
1017
+ ensureOpenSequenceBeforeDocEvent,
1018
+ noteUserActivity,
1019
+ claim,
1020
+ /** Arm the latch at bringup: fires immediately for 'headless', releases
1021
+ * the barrier for 'off', waits for an adapter or user activity for 'auto'. */
1022
+ arm: maybeFireOpenSequence,
1023
+ api: { setUiAdapter: (adapter) => {
1024
+ ports.uiAdapter = adapter;
1025
+ if (adapter) maybeFireOpenSequence();
1026
+ return () => {
1027
+ if (ports.uiAdapter === adapter) ports.uiAdapter = null;
1028
+ };
1029
+ } }
1030
+ };
1031
+ }
1032
+ //#endregion
1033
+ //#region src/lifecycle/page-lifecycle.ts
1034
+ /**
1035
+ * The lifecycle coordinator's page half: the document-open barrier and the
1036
+ * cascade budget. The stage owns page truth (it reports through
1037
+ * `reportPageState`); this owns when page-lifecycle triggers fire: nothing
1038
+ * emits before the document-open sequence has run (or was declared off or
1039
+ * headless), and emission is a diff against the last-emitted state, so
1040
+ * pre-open motion collapses to one open with no phantom close.
1041
+ */
1042
+ /** Consecutive programmatic rounds after which page-lifecycle emission is suppressed. */
1043
+ const CASCADE_LIMIT = 8;
1044
+ function createPageLifecycle(ctx, { events, settings }, { dispatch }) {
1045
+ const { reportDiagnostic } = events;
1044
1046
  let barrierOpen = false;
1045
1047
  let bufferedReport = null;
1046
1048
  let lastEmitted = {
1047
- currentPon: null,
1049
+ current: null,
1048
1050
  visible: /* @__PURE__ */ new Set()
1049
1051
  };
1050
1052
  let cascadeRounds = 0;
1051
- let openFired = false;
1052
- let sawUserActivity = false;
1053
1053
  const emitForReport = (report) => {
1054
1054
  if (report.cause === "user") cascadeRounds = 0;
1055
- const nextVisible = new Set(report.visiblePons);
1056
- const changedCurrent = report.currentPon !== lastEmitted.currentPon;
1057
- const leaving = [...lastEmitted.visible].filter((pon) => !nextVisible.has(pon));
1058
- const entering = [...nextVisible].filter((pon) => !lastEmitted.visible.has(pon));
1055
+ const current = report.currentPage === null ? null : report.currentPage.objectNumber;
1056
+ const nextVisible = new Set(report.visiblePages.map((page) => page.objectNumber));
1057
+ const changedCurrent = current !== lastEmitted.current;
1058
+ const leaving = [...lastEmitted.visible].filter((pageObjectNumber) => !nextVisible.has(pageObjectNumber));
1059
+ const entering = [...nextVisible].filter((pageObjectNumber) => !lastEmitted.visible.has(pageObjectNumber));
1059
1060
  if (!changedCurrent && leaving.length === 0 && entering.length === 0) return;
1060
- const previousCurrent = lastEmitted.currentPon;
1061
+ const previousCurrent = lastEmitted.current;
1061
1062
  lastEmitted = {
1062
- currentPon: report.currentPon,
1063
+ current,
1063
1064
  visible: nextVisible
1064
1065
  };
1065
1066
  if (report.cause === "programmatic") {
1066
1067
  cascadeRounds += 1;
1067
- if (cascadeRounds > CASCADE_CAP) {
1068
- diagnosticHook.emit({
1068
+ if (cascadeRounds > CASCADE_LIMIT) {
1069
+ reportDiagnostic({
1069
1070
  code: "cascade-budget",
1070
- message: `page-lifecycle emission suppressed: ${cascadeRounds} consecutive programmatic rounds (cap ${CASCADE_CAP})`
1071
+ message: `page-lifecycle emission suppressed: ${cascadeRounds} consecutive programmatic rounds (cap ${CASCADE_LIMIT})`
1071
1072
  });
1072
1073
  return;
1073
1074
  }
@@ -1075,22 +1076,22 @@ function createActionsCapability(ctx, config = {}) {
1075
1076
  if (changedCurrent && previousCurrent !== null) dispatch({
1076
1077
  scope: "page",
1077
1078
  event: "close",
1078
- pon: previousCurrent
1079
+ page: toPageRef(previousCurrent)
1079
1080
  });
1080
- for (const pon of leaving) dispatch({
1081
+ for (const pageObjectNumber of leaving) dispatch({
1081
1082
  scope: "page",
1082
1083
  event: "invisible",
1083
- pon
1084
+ page: toPageRef(pageObjectNumber)
1084
1085
  });
1085
- for (const pon of entering) dispatch({
1086
+ for (const pageObjectNumber of entering) dispatch({
1086
1087
  scope: "page",
1087
1088
  event: "visible",
1088
- pon
1089
+ page: toPageRef(pageObjectNumber)
1089
1090
  });
1090
- if (changedCurrent && report.currentPon !== null) dispatch({
1091
+ if (changedCurrent && current !== null) dispatch({
1091
1092
  scope: "page",
1092
1093
  event: "open",
1093
- pon: report.currentPon
1094
+ page: toPageRef(current)
1094
1095
  });
1095
1096
  };
1096
1097
  const reportPageState = (report) => {
@@ -1102,329 +1103,842 @@ function createActionsCapability(ctx, config = {}) {
1102
1103
  }
1103
1104
  emitForReport(report);
1104
1105
  };
1105
- const releaseBarrier = (fireFallback) => {
1106
- barrierOpen = true;
1107
- const report = bufferedReport;
1108
- bufferedReport = null;
1109
- try {
1110
- if (report) emitForReport(report);
1111
- else if (fireFallback && config.openSequence === "headless") {
1112
- const first = ctx.document()?.pages[0]?.pageObjectNumber;
1113
- if (first !== void 0) {
1114
- lastEmitted = {
1115
- currentPon: first,
1116
- visible: lastEmitted.visible
1117
- };
1118
- dispatch({
1119
- scope: "page",
1120
- event: "open",
1121
- pon: first
1122
- });
1123
- }
1124
- }
1125
- } catch (error) {
1126
- diagnosticHook.emit({
1127
- code: "trigger-failed",
1128
- message: `barrier release failed: ${error instanceof Error ? error.message : String(error)}`
1106
+ const releaseBarrier = (fireFallback) => {
1107
+ barrierOpen = true;
1108
+ const report = bufferedReport;
1109
+ bufferedReport = null;
1110
+ try {
1111
+ if (report) emitForReport(report);
1112
+ else if (fireFallback && settings.get().openSequence === "headless") {
1113
+ const first = ctx.document()?.pages[0]?.ref.objectNumber;
1114
+ if (first !== void 0) {
1115
+ lastEmitted = {
1116
+ current: first,
1117
+ visible: lastEmitted.visible
1118
+ };
1119
+ dispatch({
1120
+ scope: "page",
1121
+ event: "open",
1122
+ page: toPageRef(first)
1123
+ });
1124
+ }
1125
+ }
1126
+ } catch (error) {
1127
+ reportDiagnostic({
1128
+ code: "trigger-failed",
1129
+ message: `barrier release failed: ${error instanceof Error ? error.message : String(error)}`
1130
+ });
1131
+ }
1132
+ };
1133
+ return {
1134
+ releaseBarrier,
1135
+ /** A user gesture resets the programmatic-cascade counter. */
1136
+ resetCascade: () => {
1137
+ cascadeRounds = 0;
1138
+ },
1139
+ api: { reportPageState }
1140
+ };
1141
+ }
1142
+ //#endregion
1143
+ //#region src/scripting/world.ts
1144
+ /**
1145
+ * Script world building (a page-scoped prefetch: the form as the user sees it
1146
+ * plus the annotations of the page the action runs on) and the event bridge between
1147
+ * two standards: the /AA keys are ISO 32000-2 Table 200; the event names
1148
+ * live in the JavaScript API layer (ISO 21757-1, Acrobat).
1149
+ */
1150
+ function createScriptWorld(ctx, { form }) {
1151
+ const engineColorToArray = (color) => {
1152
+ if (!color) return ["T"];
1153
+ const { r, g, b } = rgbOf(color);
1154
+ return [
1155
+ "RGB",
1156
+ r / 255,
1157
+ g / 255,
1158
+ b / 255
1159
+ ];
1160
+ };
1161
+ const scriptWorldFor = async (pageObjectNumber) => {
1162
+ const page = toPageRef$1(pageObjectNumber);
1163
+ const snapshot = await form.read();
1164
+ const layout = ctx.getPage(page);
1165
+ const pageIndex = Math.max(0, layout?.index ?? -1);
1166
+ const { annotations } = await ctx.doc.page(page).annotations.list();
1167
+ const annots = annotations.filter((annotation) => annotation.subtype !== "link" && !annotation.subtype.startsWith("widget")).map((annotation) => {
1168
+ const inFile = layout ? pdfRectOf(annotation.rect, layout.pdfCropBox) : null;
1169
+ const styled = annotation;
1170
+ return {
1171
+ ref: annotation.ref,
1172
+ name: annotation.nm ?? "",
1173
+ subtype: annotation.subtype,
1174
+ page: pageIndex,
1175
+ rect: inFile ? [
1176
+ inFile.left,
1177
+ inFile.bottom,
1178
+ inFile.right,
1179
+ inFile.top
1180
+ ] : [
1181
+ 0,
1182
+ 0,
1183
+ 0,
1184
+ 0
1185
+ ],
1186
+ contents: annotation.contents ?? "",
1187
+ author: annotation.author ?? "",
1188
+ subject: annotation.subject ?? "",
1189
+ strokeColor: engineColorToArray(styled.color),
1190
+ fillColor: engineColorToArray(styled.interiorColor),
1191
+ opacity: styled.opacity ?? 1,
1192
+ width: styled.strokeWidth ?? 1,
1193
+ borderStyle: styled.borderStyle === "dashed" ? "D" : "S",
1194
+ dash: styled.dashArray ?? [],
1195
+ hidden: annotation.hidden,
1196
+ print: annotation.print,
1197
+ readOnly: annotation.readOnly,
1198
+ locked: annotation.locked,
1199
+ noView: annotation.noView,
1200
+ toggleNoView: annotation.toggleNoView,
1201
+ opaqueBody: annotation.subtype === "stamp"
1202
+ };
1203
+ });
1204
+ return {
1205
+ snapshot,
1206
+ world: {
1207
+ fields: scriptFieldsFromSnapshot(snapshot),
1208
+ annots,
1209
+ annotPages: [pageIndex],
1210
+ annotsCoverDocument: (ctx.document()?.pageCount ?? 0) <= 1
1211
+ }
1212
+ };
1213
+ };
1214
+ const ANNOTATION_EVENT_NAMES = {
1215
+ cursorEnter: "Mouse Enter",
1216
+ cursorExit: "Mouse Exit",
1217
+ mouseDown: "Mouse Down",
1218
+ mouseUp: "Mouse Up",
1219
+ focus: "Focus",
1220
+ blur: "Blur"
1221
+ };
1222
+ const PAGE_EVENT_NAMES = {
1223
+ open: "Open",
1224
+ close: "Close",
1225
+ visible: "Visible",
1226
+ invisible: "Invisible"
1227
+ };
1228
+ const DOC_EVENT_NAMES = {
1229
+ open: "Open",
1230
+ "will-save": "WillSave",
1231
+ "did-save": "DidSave",
1232
+ "will-print": "WillPrint",
1233
+ "did-print": "DidPrint",
1234
+ "will-close": "WillClose"
1235
+ };
1236
+ const scriptEventFor = (actionContext, snapshot) => {
1237
+ const provenance = actionContext.event;
1238
+ const type = provenance.scope === "page" ? "Page" : provenance.scope === "document" ? "Doc" : actionContext.source.kind === "widget" ? "Field" : actionContext.source.kind === "link" ? "Link" : actionContext.source.kind === "outline" ? "Bookmark" : "Annot";
1239
+ const name = provenance.scope === "activate" ? "Mouse Up" : provenance.scope === "annotation" ? ANNOTATION_EVENT_NAMES[provenance.name] ?? "Mouse Up" : provenance.scope === "page" ? PAGE_EVENT_NAMES[provenance.name] ?? "Open" : DOC_EVENT_NAMES[provenance.name] ?? "Open";
1240
+ const targetRef = actionContext.source.kind === "widget" ? actionContext.source.field : void 0;
1241
+ const targetField = targetRef ? snapshot.fields.find((field) => targetRef.kind === "objectNumber" && field.ref.kind === "objectNumber" ? field.ref.objectNumber === targetRef.objectNumber : targetRef.kind === "fqn" && field.name === targetRef.name) : void 0;
1242
+ return {
1243
+ kind: "widget-activate",
1244
+ type,
1245
+ name,
1246
+ ...targetRef ? {
1247
+ target: targetRef,
1248
+ source: targetRef
1249
+ } : {},
1250
+ ...targetField && targetField.valueEntry.kind === "scalar" ? { value: targetField.valueEntry.value } : {}
1251
+ };
1252
+ };
1253
+ return {
1254
+ scriptWorldFor,
1255
+ scriptEventFor
1256
+ };
1257
+ }
1258
+ //#endregion
1259
+ //#region src/scripting/executor.ts
1260
+ /** The default cap on JavaScript nodes one dispatch may run. */
1261
+ const DEFAULT_SCRIPT_NODES_PER_DISPATCH = 16;
1262
+ function registerScriptExecutor(ctx, services, environment, { scriptHost }, { performSubmit }, { surfaceScriptResult }, documentEvents) {
1263
+ const { reportDiagnostic } = services.events;
1264
+ const ports = services.ports.slots;
1265
+ const { budget } = services.queue;
1266
+ const { firePrintThroughAdapter } = documentEvents;
1267
+ const { scriptWorldFor, scriptEventFor } = createScriptWorld(ctx, services);
1268
+ /** Commit one run's document effects through the owner sinks in the
1269
+ * declared order (form first, then annotations); the first failure skips
1270
+ * the rest across both streams. Returns the failure summary, if any. */
1271
+ const commitScriptOutput = async (output, diagnose) => {
1272
+ let failure = null;
1273
+ if (output.formEffects.length > 0) if (!ports.formCommitSink) diagnose({
1274
+ code: "no-commit-sink",
1275
+ message: `script form effects dropped: no form commit sink registered (${output.formEffects.length})`
1276
+ });
1277
+ else {
1278
+ const result = await ports.formCommitSink(output.formEffects);
1279
+ if (result.status === "failed") failure = `form effects: ${result.error ?? "failed"}`;
1280
+ }
1281
+ if (output.annotEffects.length > 0) if (failure) diagnose({
1282
+ code: "executor-failed",
1283
+ message: `script annot effects skipped after form failure (${output.annotEffects.length})`
1284
+ });
1285
+ else if (!ports.annotCommitSink) diagnose({
1286
+ code: "no-commit-sink",
1287
+ message: `script annot effects dropped: no annotation commit sink registered (${output.annotEffects.length})`
1288
+ });
1289
+ else {
1290
+ const entries = output.annotEffects.map((effect) => ({
1291
+ annotObjectNumber: effect.ref.kind === "objectNumber" ? effect.ref.objectNumber : -1,
1292
+ ...effect.ref.kind === "objectNumber" ? { page: effect.ref.page } : {},
1293
+ patch: effect.patch
1294
+ }));
1295
+ const firstFailure = (await ports.annotCommitSink(entries)).results.find((entry) => entry.status === "failed");
1296
+ if (firstFailure) failure = `annotation ${firstFailure.annotObjectNumber}: ${firstFailure.error ?? "failed"}`;
1297
+ }
1298
+ if (failure) diagnose({
1299
+ code: "executor-failed",
1300
+ message: `script commit: ${failure}`
1301
+ });
1302
+ return failure;
1303
+ };
1304
+ if (!scriptHost) return;
1305
+ const nodeLimit = environment.maxScriptNodesPerDispatch ?? DEFAULT_SCRIPT_NODES_PER_DISPATCH;
1306
+ ports.executors.set("javascript", async (node, actionContext) => {
1307
+ if (node.type !== "javascript") return {
1308
+ status: "inert",
1309
+ reason: "not a JS node"
1310
+ };
1311
+ if (budget.scriptNodes >= nodeLimit) return {
1312
+ status: "inert",
1313
+ reason: `dispatch script budget exhausted (${nodeLimit} JS nodes)`
1314
+ };
1315
+ budget.scriptNodes += 1;
1316
+ const source = actionContext.source;
1317
+ const firstPage = ctx.document()?.pages[0]?.ref.objectNumber;
1318
+ const pageObjectNumber = source.kind === "widget" || source.kind === "page" ? source.page.objectNumber : source.kind === "link" || source.kind === "annotation" ? source.page?.objectNumber ?? firstPage : firstPage;
1319
+ if (pageObjectNumber === void 0) return {
1320
+ status: "inert",
1321
+ reason: "no page to anchor the world on"
1322
+ };
1323
+ const diagnose = (diagnostic) => reportDiagnostic(diagnostic, {
1324
+ action: "javascript",
1325
+ source
1326
+ });
1327
+ const pendingExternal = [];
1328
+ const splitExternal = (output, phase) => output.uiEffects.filter((effect) => {
1329
+ if (effect.kind === "print" || effect.kind === "submitForm") {
1330
+ pendingExternal.push({
1331
+ effect,
1332
+ phase
1333
+ });
1334
+ return false;
1335
+ }
1336
+ return true;
1337
+ });
1338
+ const outcome = await scriptHost.transaction(async (transaction) => {
1339
+ let built = await scriptWorldFor(pageObjectNumber);
1340
+ const boot = await transaction.boot({
1341
+ ...built.world,
1342
+ event: {
1343
+ kind: "name-tree-boot",
1344
+ type: "Doc",
1345
+ name: "Open"
1346
+ }
1347
+ });
1348
+ if (boot) {
1349
+ await commitScriptOutput(boot, diagnose);
1350
+ surfaceScriptResult({
1351
+ uiEffects: splitExternal(boot, "boot"),
1352
+ diagnostics: boot.diagnostics,
1353
+ ...boot.error ? { error: boot.error } : {},
1354
+ origin: actionContext.origin,
1355
+ phase: "boot",
1356
+ realm: "document",
1357
+ source
1358
+ });
1359
+ if (boot.formEffects.length || boot.annotEffects.length) built = await scriptWorldFor(pageObjectNumber);
1360
+ }
1361
+ const output = await transaction.run(node.script, {
1362
+ ...built.world,
1363
+ event: scriptEventFor(actionContext, built.snapshot)
1364
+ });
1365
+ surfaceScriptResult({
1366
+ uiEffects: splitExternal(output, "user"),
1367
+ diagnostics: output.diagnostics,
1368
+ ...output.error ? { error: output.error } : {},
1369
+ origin: actionContext.origin,
1370
+ phase: "user",
1371
+ realm: "document",
1372
+ source
1373
+ });
1374
+ if (output.error) return {
1375
+ status: "failed",
1376
+ error: output.error.message
1377
+ };
1378
+ const failure = await commitScriptOutput(output, diagnose);
1379
+ return failure ? {
1380
+ status: "failed",
1381
+ error: failure
1382
+ } : { status: "executed" };
1383
+ });
1384
+ for (const entry of pendingExternal) if (entry.effect.kind === "print") await firePrintThroughAdapter({
1385
+ origin: actionContext.origin,
1386
+ phase: entry.phase
1387
+ }, diagnose);
1388
+ else await performSubmit(intentOfSubmitEffect(entry.effect), actionContext, diagnose);
1389
+ return outcome;
1390
+ });
1391
+ }
1392
+ //#endregion
1393
+ //#region src/scripting/environment.ts
1394
+ function createScriptRealmFactory(environment, identity, identityDoc) {
1395
+ const sandboxFactory = environment.sandboxFactory ?? (() => import("@embedpdf/core-js-sandbox").then(({ createQuickJsSandbox }) => createQuickJsSandbox()));
1396
+ const budget = environment.budget ?? DEFAULT_SCRIPT_BUDGET;
1397
+ return {
1398
+ budget,
1399
+ realmFor: (target) => createScriptHost({
1400
+ sandboxFactory,
1401
+ document: () => {
1402
+ const documentInfo = target.document();
1403
+ return {
1404
+ id: target.doc.id,
1405
+ fileName: environment.fileName?.() ?? documentInfo?.name ?? "document.pdf",
1406
+ pageCount: documentInfo?.pageCount ?? 0,
1407
+ pageNumber: 0
1408
+ };
1409
+ },
1410
+ identity: () => resolveScriptIdentity(identityDoc, identity()),
1411
+ environment: (sequence) => {
1412
+ const nowMs = environment.now?.() ?? Date.now();
1413
+ return {
1414
+ nowMs,
1415
+ utcOffsetMinutes: environment.utcOffsetMinutes?.() ?? -new Date(nowMs).getTimezoneOffset(),
1416
+ randomSeed: environment.randomSeed?.() ?? seedFrom(target.doc.id, sequence)
1417
+ };
1418
+ },
1419
+ bootSources: target.bootSources,
1420
+ budget
1421
+ })
1422
+ };
1423
+ }
1424
+ //#endregion
1425
+ //#region src/scripting/realm.ts
1426
+ /**
1427
+ * The one ScriptHost per document. The `javascript.enabled` setting, as it is
1428
+ * when the document opens, decides whether a realm factory exists; the
1429
+ * factory carries the session environment and mints this document's own
1430
+ * realm here, and detached realms on demand (host lens): one environment, any
1431
+ * number of isolated realms.
1432
+ */
1433
+ function createRealm(ctx, { catalog, settings }, environment) {
1434
+ const { readDocumentActions } = catalog;
1435
+ const realms = settings.get().javascript.enabled ? createScriptRealmFactory(environment, () => settings.get().javascript.identity ?? void 0, ctx.doc) : null;
1436
+ const scriptHost = realms ? realms.realmFor({
1437
+ doc: ctx.doc,
1438
+ document: () => ctx.document(),
1439
+ bootSources: async () => {
1440
+ return ((await readDocumentActions())?.nameTreeScripts ?? []).map(({ action }) => javaScriptProgramFromActionTree(action));
1441
+ }
1442
+ }) : null;
1443
+ if (scriptHost) ctx.cleanup(() => scriptHost.dispose());
1444
+ return {
1445
+ scriptHost,
1446
+ api: {
1447
+ isScriptingEnabled: () => scriptHost !== null,
1448
+ ...scriptHost && realms ? {
1449
+ scriptRealm: {
1450
+ transaction: (body) => scriptHost.transaction(body),
1451
+ budget: realms.budget
1452
+ },
1453
+ createDetachedScriptRealm: (target) => {
1454
+ const host = realms.realmFor(target);
1455
+ return {
1456
+ transaction: (body) => host.transaction(body),
1457
+ budget: realms.budget,
1458
+ dispose: () => host.dispose()
1459
+ };
1460
+ }
1461
+ } : {}
1462
+ }
1463
+ };
1464
+ }
1465
+ //#endregion
1466
+ //#region src/scripting/surface.ts
1467
+ function createScriptSurface(services, { performSubmit }) {
1468
+ const { reportDiagnostic, scriptDiagnosticReported, scriptFailed } = services.events;
1469
+ const ports = services.ports.slots;
1470
+ const { allowsPrint } = services.authority;
1471
+ const { documentPrint } = services;
1472
+ const printLatch = services.printLatch;
1473
+ const surfaceScriptResult = (result) => {
1474
+ const uiContext = {
1475
+ origin: result.origin,
1476
+ phase: result.phase
1477
+ };
1478
+ const source = result.source ?? null;
1479
+ for (const effect of result.uiEffects) {
1480
+ if (result.realm === "detached" && effect.kind !== "alert") {
1481
+ scriptDiagnosticReported.emit({
1482
+ code: "ui-effect-suppressed",
1483
+ message: `script ${effect.kind} request from a detached realm suppressed: no document surface`
1484
+ });
1485
+ continue;
1486
+ }
1487
+ if (effect.kind === "submitForm") {
1488
+ performSubmit(intentOfSubmitEffect(effect), {
1489
+ origin: result.origin,
1490
+ source: result.source ?? { kind: "api" },
1491
+ event: { scope: "activate" }
1492
+ }, (diagnostic) => reportDiagnostic(diagnostic, {
1493
+ action: "javascript",
1494
+ source
1495
+ }));
1496
+ continue;
1497
+ }
1498
+ if (effect.kind === "print" && !allowsPrint()) {
1499
+ scriptDiagnosticReported.emit({
1500
+ code: "ui-effect-suppressed",
1501
+ message: "script print request withheld: doc.print is not allowed"
1502
+ });
1503
+ continue;
1504
+ }
1505
+ if (effect.kind === "print" && printLatch.active) {
1506
+ scriptDiagnosticReported.emit({
1507
+ code: "ui-effect-suppressed",
1508
+ message: "script print request during a document print event — suppressed (reentrant)"
1509
+ });
1510
+ continue;
1511
+ }
1512
+ if (effect.kind === "print" && documentPrint.available()) {
1513
+ if (result.origin !== "user") {
1514
+ scriptDiagnosticReported.emit({
1515
+ code: "ui-effect-suppressed",
1516
+ message: `script print request from a ${result.origin} event suppressed`
1517
+ });
1518
+ continue;
1519
+ }
1520
+ documentPrint.start().catch((error) => reportDiagnostic({
1521
+ code: "trigger-failed",
1522
+ message: `print: ${error instanceof Error ? error.message : String(error)}`
1523
+ }, {
1524
+ action: "javascript",
1525
+ source
1526
+ }));
1527
+ continue;
1528
+ }
1529
+ if (!ports.uiAdapter) {
1530
+ reportDiagnostic({
1531
+ code: "no-adapter",
1532
+ message: `script ${effect.kind}: no UI adapter installed`
1533
+ }, {
1534
+ action: "javascript",
1535
+ source
1536
+ });
1537
+ continue;
1538
+ }
1539
+ if (effect.kind === "alert") ports.uiAdapter.alert?.(effect.message, {
1540
+ ...uiContext,
1541
+ icon: effect.icon,
1542
+ ...effect.title !== void 0 ? { title: effect.title } : {}
1543
+ });
1544
+ else if (effect.kind === "gotoPage") ports.uiAdapter.gotoPage?.(effect.page, uiContext);
1545
+ else ports.uiAdapter.print(uiContext);
1546
+ }
1547
+ for (const diagnostic of result.diagnostics) scriptDiagnosticReported.emit(diagnostic);
1548
+ if (result.error) scriptFailed.emit({
1549
+ error: result.error,
1550
+ source
1551
+ });
1552
+ };
1553
+ /** A K/V/C/F commit surfaces as up to two results: the boot phase (only
1554
+ * when it produced effects) and the user phase (always — it carries the
1555
+ * diagnostics and the error). */
1556
+ const surfaceScriptCommit = (commit, context) => {
1557
+ for (const phase of ["boot", "user"]) {
1558
+ const uiEffects = commit.uiEffects.filter((effect) => effect.phase === phase);
1559
+ if (uiEffects.length === 0 && phase === "boot") continue;
1560
+ surfaceScriptResult({
1561
+ uiEffects,
1562
+ diagnostics: phase === "user" ? commit.diagnostics : [],
1563
+ ...phase === "user" && commit.error ? { error: commit.error } : {},
1564
+ origin: context.origin,
1565
+ phase,
1566
+ realm: context.realm,
1567
+ ...context.source ? { source: context.source } : {}
1568
+ });
1569
+ }
1570
+ };
1571
+ return {
1572
+ surfaceScriptResult,
1573
+ api: {
1574
+ surfaceScriptResult,
1575
+ surfaceScriptCommit
1576
+ }
1577
+ };
1578
+ }
1579
+ //#endregion
1580
+ //#region src/services/authority.ts
1581
+ function createAuthority(ctx) {
1582
+ const allowsPrint = () => ctx.allows("doc.print");
1583
+ return { allowsPrint };
1584
+ }
1585
+ //#endregion
1586
+ //#region src/services/catalog.ts
1587
+ /** The ISO 32000-2 Table 200 key for each verb-shaped document trigger event. */
1588
+ const DOC_EVENT_TREES = {
1589
+ "will-save": "willSave",
1590
+ "did-save": "didSave",
1591
+ "will-print": "willPrint",
1592
+ "did-print": "didPrint",
1593
+ "will-close": "willClose"
1594
+ };
1595
+ function createCatalog(ctx) {
1596
+ let snapshotRead = null;
1597
+ const readDocumentActions = () => {
1598
+ if (!snapshotRead) {
1599
+ const service = ctx.doc.actions;
1600
+ const memoized = (service ? Promise.resolve(service.get()) : Promise.resolve(null)).catch((error) => {
1601
+ if (snapshotRead === memoized) snapshotRead = null;
1602
+ throw error;
1603
+ });
1604
+ snapshotRead = memoized;
1605
+ }
1606
+ return snapshotRead;
1607
+ };
1608
+ return {
1609
+ readDocumentActions,
1610
+ DOC_EVENT_TREES
1611
+ };
1612
+ }
1613
+ //#endregion
1614
+ //#region src/services/document-print.ts
1615
+ /**
1616
+ * The kernel's print, where the document's own print requests go (the Named Print verb, a
1617
+ * script's `print()`) when the host gave the kernel a printer. The kernel prints the
1618
+ * document's pages, and runs the document's /WP and /DP around the dialog through the wrap
1619
+ * this plugin registers (`ctx.aroundPrint`). Without a printer, requests fall back to the UI
1620
+ * adapter's `print`.
1621
+ */
1622
+ function createDocumentPrint(ctx) {
1623
+ const documents = () => ctx.get(DocumentsToken);
1624
+ return {
1625
+ /** Whether the kernel prints this document: `doc.print`, and the host gave a printer. */
1626
+ available: () => documents().canPrint(),
1627
+ /** Print the document through the kernel. */
1628
+ start: () => documents().print()
1629
+ };
1630
+ }
1631
+ //#endregion
1632
+ //#region src/services/events.ts
1633
+ function createEvents(ctx) {
1634
+ const executed = ctx.events.source();
1635
+ const diagnosticReported = ctx.events.source();
1636
+ return {
1637
+ executed,
1638
+ diagnosticReported,
1639
+ scriptDiagnosticReported: ctx.events.source(),
1640
+ scriptFailed: ctx.events.source(),
1641
+ openSequenceCompleted: ctx.events.source(),
1642
+ /** Fire `onDiagnosticReported` with the action and source it concerns (`null` for none). */
1643
+ reportDiagnostic(diagnostic, place = {}) {
1644
+ diagnosticReported.emit({
1645
+ code: diagnostic.code,
1646
+ action: place.action ?? null,
1647
+ source: place.source ?? null
1129
1648
  });
1130
1649
  }
1131
1650
  };
1132
- /** The §3.9 sequence body — runs INSIDE the queue (via dispatch or the
1133
- * latch's own enqueue; callers guarantee openFired was set). */
1134
- const runOpenSequenceOp = async () => {
1135
- const diagnostics = [];
1136
- const steps = [];
1137
- const lifecycleCtx = {
1138
- origin: "lifecycle",
1139
- source: { kind: "document" },
1140
- event: {
1141
- scope: "document",
1142
- name: "open"
1143
- }
1144
- };
1145
- try {
1146
- const snapshot = await readDocumentActions();
1147
- if (snapshot?.openDestination) {
1148
- const tree = {
1149
- root: {
1150
- type: "goto",
1151
- subtype: "GoTo",
1152
- destination: snapshot.openDestination,
1153
- next: []
1154
- },
1155
- incomplete: false,
1156
- warningFlags: 0,
1157
- warnings: []
1651
+ }
1652
+ //#endregion
1653
+ //#region src/services/form.ts
1654
+ function createForm(ctx, ports) {
1655
+ return { read: (read = (task) => task) => {
1656
+ const shown = ports.formReader?.();
1657
+ return shown ? Promise.resolve(shown) : read(ctx.doc.forms.list());
1658
+ } };
1659
+ }
1660
+ //#endregion
1661
+ //#region src/services/ports.ts
1662
+ function createPorts({ reportDiagnostic }) {
1663
+ const ports = {
1664
+ executors: /* @__PURE__ */ new Map(),
1665
+ annotCommitSink: null,
1666
+ formCommitSink: null,
1667
+ formReader: null,
1668
+ uiAdapter: null,
1669
+ submitHandler: null,
1670
+ submitResolver: null
1671
+ };
1672
+ return {
1673
+ slots: ports,
1674
+ api: {
1675
+ registerExecutor: (type, executor) => {
1676
+ if (ports.executors.has(type)) reportDiagnostic({
1677
+ code: "duplicate-executor",
1678
+ message: `executor for '${type}' replaced (last-wins)`
1679
+ }, { action: type });
1680
+ ports.executors.set(type, executor);
1681
+ return () => {
1682
+ if (ports.executors.get(type) === executor) ports.executors.delete(type);
1683
+ };
1684
+ },
1685
+ registerAnnotCommitSink: (sink) => {
1686
+ ports.annotCommitSink = sink;
1687
+ return () => {
1688
+ if (ports.annotCommitSink === sink) ports.annotCommitSink = null;
1689
+ };
1690
+ },
1691
+ registerFormCommitSink: (sink) => {
1692
+ ports.formCommitSink = sink;
1693
+ return () => {
1694
+ if (ports.formCommitSink === sink) ports.formCommitSink = null;
1695
+ };
1696
+ },
1697
+ registerFormReader: (reader) => {
1698
+ ports.formReader = reader;
1699
+ return () => {
1700
+ if (ports.formReader === reader) ports.formReader = null;
1701
+ };
1702
+ },
1703
+ setSubmitHandler: (handler) => {
1704
+ ports.submitHandler = handler;
1705
+ return () => {
1706
+ if (ports.submitHandler === handler) ports.submitHandler = null;
1707
+ };
1708
+ },
1709
+ registerSubmitResolver: (resolver) => {
1710
+ ports.submitResolver = resolver;
1711
+ return () => {
1712
+ if (ports.submitResolver === resolver) ports.submitResolver = null;
1158
1713
  };
1159
- steps.push({
1160
- source: { kind: "document" },
1161
- tree,
1162
- result: await runAndEmit(tree, lifecycleCtx)
1163
- });
1164
1714
  }
1165
- if (snapshot?.openAction?.root || snapshot?.openAction?.incomplete) steps.push({
1166
- source: { kind: "document" },
1167
- tree: snapshot.openAction,
1168
- result: await runAndEmit(snapshot.openAction, lifecycleCtx)
1169
- });
1170
- } catch (error) {
1171
- const diagnostic = {
1172
- code: "trigger-failed",
1173
- message: `open sequence failed: ${error instanceof Error ? error.message : String(error)}`
1174
- };
1175
- diagnostics.push(diagnostic);
1176
- diagnosticHook.emit(diagnostic);
1177
- } finally {
1178
- releaseBarrier(true);
1179
1715
  }
1180
- return foldSteps(steps, diagnostics);
1181
1716
  };
1182
- /**
1183
- * D1's open-ordering law: catalog `OpenAction` may never run AFTER a
1184
- * WillSave/WillPrint/WillClose script. Before the first verb-shaped
1185
- * document event, an armed-but-unfired open sequence runs inline (we are
1186
- * already inside the queue; `runOpenSequenceOp` never enqueues). It does
1187
- * NOT wait for the initial page `/O` — the guarantee is catalog-level.
1188
- */
1189
- const ensureOpenSequenceBeforeDocEvent = async () => {
1190
- if (openFired || config.openSequence === "off") return;
1191
- openFired = true;
1192
- await runOpenSequenceOp();
1717
+ }
1718
+ //#endregion
1719
+ //#region src/services/print-latch.ts
1720
+ const createPrintLatch = () => ({ active: false });
1721
+ //#endregion
1722
+ //#region src/services/queue.ts
1723
+ function createQueue(ctx) {
1724
+ return {
1725
+ enqueue: ctx.serialQueue("actions"),
1726
+ budget: { scriptNodes: 0 }
1727
+ };
1728
+ }
1729
+ //#endregion
1730
+ //#region src/services/index.ts
1731
+ function createServices(ctx) {
1732
+ const events = createEvents(ctx);
1733
+ const ports = createPorts(events);
1734
+ return {
1735
+ settings: ctx.settings(),
1736
+ events,
1737
+ policy: createPolicy(ctx),
1738
+ ports,
1739
+ queue: createQueue(ctx),
1740
+ catalog: createCatalog(ctx),
1741
+ form: createForm(ctx, ports.slots),
1742
+ authority: createAuthority(ctx),
1743
+ documentPrint: createDocumentPrint(ctx),
1744
+ printLatch: createPrintLatch()
1193
1745
  };
1746
+ }
1747
+ //#endregion
1748
+ //#region src/submit/perform.ts
1749
+ function createSubmit(ctx, services, environment) {
1750
+ const policy = services.policy;
1751
+ const ports = services.ports.slots;
1752
+ const { reportDiagnostic } = services.events;
1753
+ const nowMs = () => environment.now?.() ?? Date.now();
1194
1754
  /**
1195
- * Run ONE catalog lifecycle tree inside the current queue operation —
1196
- * shared by the dispatch path, `runDocumentVerb`, and the print wrapper.
1197
- * Never throws: a read/resolution failure degrades to a diagnostic (a
1198
- * broken script must not cancel the user's save/print — D2).
1755
+ * The one submit door. Sink order: the embedder handler (explicit beats
1756
+ * ambient; its promise is not awaited) → the document's home
1757
+ * (`doc.forms.submit`, engine-asserted and awaited: a real operation with a
1758
+ * real receipt) → blocked with `no-submit-sink`.
1199
1759
  */
1200
- const runDocEventTreeSafe = async (event, diagnose) => {
1201
- if (config.triggers?.document === false) return null;
1202
- try {
1203
- await ensureOpenSequenceBeforeDocEvent();
1204
- const tree = (await readDocumentActions())?.[DOC_EVENT_TREES[event]];
1205
- if (!tree?.root && !tree?.incomplete) return null;
1206
- return {
1207
- source: { kind: "document" },
1208
- tree,
1209
- result: await runAndEmit(tree, {
1210
- origin: "lifecycle",
1211
- source: { kind: "document" },
1212
- event: {
1213
- scope: "document",
1214
- name: event
1215
- }
1216
- })
1217
- };
1218
- } catch (error) {
1219
- diagnose({
1220
- code: "trigger-failed",
1221
- message: `${event}: ${error instanceof Error ? error.message : String(error)}`
1760
+ const performSubmit = async (intent, actionContext, diagnose) => {
1761
+ const decision = policy.current()["submit-form"][actionContext.origin];
1762
+ if (decision !== "adapter" && decision !== "allow") {
1763
+ if (decision === "report") diagnose({
1764
+ code: "blocked",
1765
+ message: `submit-form: policy 'report' for origin '${actionContext.origin}'`
1222
1766
  });
1223
- return null;
1767
+ return { status: "blocked" };
1224
1768
  }
1225
- };
1226
- /** The dispatch-path body for a verb-shaped document event. */
1227
- const runDocumentEventOp = async (event, diagnostics, diagnose) => {
1228
- const step = await runDocEventTreeSafe(event, diagnose);
1229
- return foldSteps(step ? [step] : [], diagnostics);
1230
- };
1231
- /**
1232
- * D3: BOTH adapter print invocations (the Named Print verb; script
1233
- * `doc.print()` effects from the actions plane) go through here — WP →
1234
- * `uiAdapter.print` exactly once → DP, latch reset in `finally`. While
1235
- * the latch is held (including `runDocumentVerb('print')`'s body) a
1236
- * nested request is suppressed with `reentrant-print`. An adapter throw
1237
- * skips DP (the latch still resets) — named deviation: DP otherwise
1238
- * fires when the adapter call RETURNS, since a browser cannot observe
1239
- * dialog completion.
1240
- */
1241
- const firePrintThroughAdapter = async (uiContext, diagnose) => {
1242
- if (docPrintEventActive) {
1769
+ if (!ports.submitResolver) {
1243
1770
  diagnose({
1244
- code: "reentrant-print",
1245
- message: "print request during a document print event — suppressed (one dialog per request)"
1771
+ code: "no-submit-resolver",
1772
+ message: "submit-form: no dataset resolver registered (form plugin missing?)"
1246
1773
  });
1247
1774
  return {
1248
1775
  status: "blocked",
1249
- detail: "reentrant print suppressed"
1776
+ detail: "no dataset resolver"
1250
1777
  };
1251
1778
  }
1252
- if (!allowsPrint()) {
1779
+ let request;
1780
+ try {
1781
+ request = await ports.submitResolver(intent, actionContext, diagnose);
1782
+ } catch (error) {
1783
+ const detail = error instanceof Error ? error.message : String(error);
1253
1784
  diagnose({
1254
- code: "blocked",
1255
- message: "print: doc.print is not allowed"
1785
+ code: "executor-failed",
1786
+ message: `submit-form: dataset resolution: ${detail}`
1256
1787
  });
1257
1788
  return {
1258
- status: "blocked",
1259
- detail: "doc.print is not allowed"
1789
+ status: "failed",
1790
+ detail
1260
1791
  };
1261
1792
  }
1262
- if (!uiAdapter) {
1793
+ const forms = ctx.doc.forms;
1794
+ const homeSubmit = typeof forms.submit === "function" ? () => Promise.resolve(forms.submit(toFormSubmissionRequest(request, new Date(nowMs()).toISOString()))) : null;
1795
+ if (ports.submitHandler) try {
1796
+ const outcome = ports.submitHandler(request, { submitToDocumentHome: homeSubmit });
1797
+ if (outcome && typeof outcome.then === "function") outcome.catch((error) => {
1798
+ reportDiagnostic({
1799
+ code: "executor-failed",
1800
+ message: `submit handler rejected (detached): ${error instanceof Error ? error.message : String(error)}`
1801
+ }, {
1802
+ action: "submit-form",
1803
+ source: actionContext.source
1804
+ });
1805
+ });
1806
+ return { status: "executed" };
1807
+ } catch (error) {
1808
+ const detail = error instanceof Error ? error.message : String(error);
1263
1809
  diagnose({
1264
- code: "no-adapter",
1265
- message: "print: no UI adapter installed"
1810
+ code: "executor-failed",
1811
+ message: `submit handler threw: ${detail}`
1266
1812
  });
1267
1813
  return {
1268
- status: "no-executor",
1269
- detail: "no UI adapter installed"
1814
+ status: "failed",
1815
+ detail
1270
1816
  };
1271
1817
  }
1272
- docPrintEventActive = true;
1273
- try {
1274
- await runDocEventTreeSafe("will-print", diagnose);
1275
- const adapter = uiAdapter;
1276
- if (!adapter) return {
1277
- status: "no-executor",
1278
- detail: "UI adapter uninstalled mid-print"
1279
- };
1280
- if (uiContext) adapter.print(uiContext);
1281
- else adapter.print();
1282
- await runDocEventTreeSafe("did-print", diagnose);
1818
+ if (homeSubmit) try {
1819
+ await homeSubmit();
1283
1820
  return { status: "executed" };
1284
- } finally {
1285
- docPrintEventActive = false;
1286
- }
1287
- };
1288
- const maybeFireOpenSequence = () => {
1289
- if (openFired) return;
1290
- if (config.openSequence === "off") {
1291
- openFired = true;
1292
- releaseBarrier(false);
1293
- return;
1821
+ } catch (error) {
1822
+ const detail = error instanceof Error ? error.message : String(error);
1823
+ diagnose({
1824
+ code: "executor-failed",
1825
+ message: `document home refused submit: ${detail}`
1826
+ });
1827
+ return {
1828
+ status: "failed",
1829
+ detail
1830
+ };
1294
1831
  }
1295
- if (!uiAdapter && config.openSequence !== "headless" && !sawUserActivity) return;
1296
- openFired = true;
1297
- enqueue(() => runOpenSequenceOp());
1832
+ diagnose({
1833
+ code: "no-submit-sink",
1834
+ message: "submit-form: no handler installed and the document has no submit-capable home"
1835
+ });
1836
+ return {
1837
+ status: "blocked",
1838
+ detail: "no submit sink"
1839
+ };
1298
1840
  };
1299
- const noteUserActivity = () => {
1300
- cascadeRounds = 0;
1301
- if (!sawUserActivity) {
1302
- sawUserActivity = true;
1303
- maybeFireOpenSequence();
1304
- }
1841
+ return { performSubmit };
1842
+ }
1843
+ //#endregion
1844
+ //#region src/controller.ts
1845
+ /**
1846
+ * The actions controller: the composition root. It builds the plugin's
1847
+ * services once, wires each area with the services it declares, and
1848
+ * assembles the host capability from the areas' API slices. No behavior
1849
+ * lives here: every verb and read has a home in `submit/`, `scripting/`,
1850
+ * `dispatch/` or `lifecycle/`. The plugin keeps no session state: its rules
1851
+ * are its settings, and its live data (ports, queue, latches) are resources
1852
+ * of the areas.
1853
+ */
1854
+ /**
1855
+ * `config` is what the app registered; the settings in it reach the
1856
+ * controller through `ctx.settings()`, and only the script environment is
1857
+ * read from it here.
1858
+ */
1859
+ function createActionsController(ctx, config = {}) {
1860
+ const services = createServices(ctx);
1861
+ const { events, ports, settings } = services;
1862
+ const core = {
1863
+ dispatch: (trigger) => dispatcher.dispatch(trigger),
1864
+ runAndEmit: (tree, actionContext) => dispatcher.runAndEmit(tree, actionContext)
1305
1865
  };
1306
- maybeFireOpenSequence();
1866
+ const environment = config.javascript ?? {};
1867
+ const submit = createSubmit(ctx, services, environment);
1868
+ const realm = createRealm(ctx, services, environment);
1869
+ const surface = createScriptSurface(services, submit);
1870
+ const pageLifecycle = createPageLifecycle(ctx, services, core);
1871
+ const openSequence = createOpenSequence(services, pageLifecycle, core);
1872
+ const documentEvents = createDocumentEvents(services, openSequence, core);
1873
+ const triggers = createTriggers(ctx, services);
1874
+ const dispatcher = createDispatcher(ctx, services, createRunner(ctx, services, submit, documentEvents), triggers, openSequence, documentEvents);
1875
+ registerScriptExecutor(ctx, services, environment, realm, submit, surface, documentEvents);
1307
1876
  return {
1308
- execute,
1309
- canExecute,
1310
- dispatch,
1311
- canDispatch: (trigger) => triggerEnabled(trigger) && ctx.doc !== null,
1312
- setUiAdapter: (adapter) => {
1313
- uiAdapter = adapter;
1314
- if (adapter) maybeFireOpenSequence();
1315
- return () => {
1316
- if (uiAdapter === adapter) uiAdapter = null;
1317
- };
1318
- },
1319
- runDocumentVerb: (verb, operation) => enqueue(async () => {
1320
- jsNodesThisDispatch = 0;
1321
- const diagnose = (diagnostic) => diagnosticHook.emit(diagnostic);
1322
- const before = verb === "save" ? "will-save" : "will-print";
1323
- const after = verb === "save" ? "did-save" : "did-print";
1324
- const body = async () => {
1325
- await runDocEventTreeSafe(before, diagnose);
1326
- const value = await operation();
1327
- await runDocEventTreeSafe(after, diagnose);
1328
- return value;
1329
- };
1330
- if (verb !== "print") return body();
1331
- docPrintEventActive = true;
1332
- try {
1333
- return await body();
1334
- } finally {
1335
- docPrintEventActive = false;
1336
- }
1337
- }),
1338
- prepareClose: () => dispatch({
1339
- scope: "document",
1340
- event: "will-close"
1341
- }),
1342
- setSubmitHandler: (handler) => {
1343
- submitHandler = handler;
1344
- return () => {
1345
- if (submitHandler === handler) submitHandler = null;
1346
- };
1347
- },
1348
- onAction: actionHook.on,
1349
- onDiagnostic: diagnosticHook.on,
1350
- onScriptDiagnostic: scriptDiagnosticHook.on,
1351
- onScriptError: scriptErrorHook.on,
1352
- registerExecutor: (type, executor) => {
1353
- if (executors.has(type)) diagnosticHook.emit({
1354
- code: "duplicate-executor",
1355
- message: `executor for '${type}' replaced (last-wins)`
1356
- });
1357
- executors.set(type, executor);
1358
- return () => {
1359
- if (executors.get(type) === executor) executors.delete(type);
1360
- };
1361
- },
1362
- registerAnnotCommitSink: (sink) => {
1363
- annotCommitSink = sink;
1364
- return () => {
1365
- if (annotCommitSink === sink) annotCommitSink = null;
1366
- };
1367
- },
1368
- registerFormCommitSink: (sink) => {
1369
- formCommitSink = sink;
1370
- return () => {
1371
- if (formCommitSink === sink) formCommitSink = null;
1372
- };
1373
- },
1374
- ...scriptHost && realms ? {
1375
- scriptRealm: {
1376
- transaction: (body) => scriptHost.transaction(body),
1377
- budget: realms.budget
1378
- },
1379
- createDetachedScriptRealm: (target) => {
1380
- const host = realms.realmFor(target);
1381
- return {
1382
- transaction: (body) => host.transaction(body),
1383
- budget: realms.budget,
1384
- dispose: () => host.dispose()
1385
- };
1877
+ api: composeApi("actions", [
1878
+ settings.api,
1879
+ ports.api,
1880
+ realm.api,
1881
+ surface.api,
1882
+ pageLifecycle.api,
1883
+ openSequence.api,
1884
+ documentEvents.api,
1885
+ dispatcher.api,
1886
+ {
1887
+ onExecuted: events.executed.on,
1888
+ onDiagnosticReported: events.diagnosticReported.on,
1889
+ onScriptDiagnosticReported: events.scriptDiagnosticReported.on,
1890
+ onScriptFailed: events.scriptFailed.on,
1891
+ onOpenSequenceCompleted: events.openSequenceCompleted.on
1386
1892
  }
1387
- } : {},
1388
- surfaceScriptResult,
1389
- surfaceScriptCommit,
1390
- reportPageState,
1391
- registerSubmitResolver: (resolver) => {
1392
- submitResolver = resolver;
1393
- return () => {
1394
- if (submitResolver === resolver) submitResolver = null;
1395
- };
1893
+ ]),
1894
+ connect() {
1895
+ ctx.listen(ctx.doc.events, (event) => triggers.invalidate(event));
1896
+ openSequence.arm();
1897
+ ctx.aroundDownload((read) => documentEvents.api.runDocumentVerb("save", read));
1898
+ ctx.aroundPrint((print) => documentEvents.api.runDocumentVerb("print", print));
1396
1899
  }
1397
1900
  };
1398
1901
  }
1399
1902
  //#endregion
1400
- //#region src/reducer.ts
1401
- const initialActionsState = () => ({ seq: 0 });
1402
- function actionsReducer(state, action) {
1403
- switch (action.type) {
1404
- case "ACTIONS_DISPATCHED": return { seq: state.seq + 1 };
1405
- default: return state;
1406
- }
1903
+ //#region src/settings.ts
1904
+ /** `config` without the script environment, which is no setting: what the settings register. */
1905
+ function registeredSettings(config) {
1906
+ if (!config) return void 0;
1907
+ const { javascript } = config;
1908
+ return {
1909
+ policy: config.policy,
1910
+ triggers: config.triggers,
1911
+ openSequence: config.openSequence,
1912
+ javascript: javascript && {
1913
+ enabled: javascript.enabled,
1914
+ identity: javascript.identity
1915
+ }
1916
+ };
1407
1917
  }
1408
1918
  //#endregion
1409
1919
  //#region src/actions.plugin.ts
1410
1920
  /**
1411
- * The action engine: the DEPENDENCY ROOT of the action architecture. It
1921
+ * The action engine, the dependency root of the action architecture. It
1412
1922
  * interprets extracted /A and /AA trees; it never detects triggers and never
1413
- * imports another plugin's token — stage, annotation, link, and form
1414
- * optionally depend on ActionsToken and register their executors and sinks
1415
- * at init (the kernel's topological order guarantees this plugin initializes
1923
+ * imports another plugin's token: the stage, annotation, link and form
1924
+ * plugins optionally depend on it and register their executors and sinks
1925
+ * when they connect (the kernel's dependency order creates this plugin
1416
1926
  * first). JavaScript is one registered interpreter among many: Hide,
1417
- * ResetForm, GoTo, and Named work with scripting off.
1927
+ * ResetForm, GoTo and Named work with scripting off. Document-scoped and
1928
+ * stateless; its settings are what the app registered over
1929
+ * {@link ACTIONS_DEFAULTS}.
1418
1930
  */
1419
1931
  const actionsPlugin = (config) => definePlugin({
1420
1932
  id: "actions",
1421
- token: ActionsToken,
1933
+ token: ActionsToken$1,
1422
1934
  scope: "document",
1423
- initialState: initialActionsState,
1424
- reduce: actionsReducer,
1425
- capability: (ctx) => createActionsCapability(ctx, config)
1935
+ settings: {
1936
+ defaults: ACTIONS_DEFAULTS,
1937
+ registered: registeredSettings(config)
1938
+ },
1939
+ create: (ctx) => createActionsController(ctx, config)
1426
1940
  });
1427
1941
  //#endregion
1428
- export { ActionsToken, actionsPlugin, createHoverPump, originOf, submitEntriesToUrlEncoded };
1942
+ export { ACTIONS_DEFAULTS, ActionsToken, actionsPlugin, createHoverPump, eventOf, submitEntriesToUrlEncoded, triggerOriginOf };
1429
1943
 
1430
1944
  //# sourceMappingURL=index.js.map