@embedpdf/plugin-actions 3.0.0-next.13 → 3.0.0-next.15

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