@holistics/anfra-sdk 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,29 @@ app.subscribe(() => render(byRegion.result));
44
44
  await app.execute();
45
45
  ```
46
46
 
47
+ A reader can download a query's whole result, with the inputs its result on screen was run with:
48
+
49
+ ```js
50
+ if (Anfra.features.export && Anfra.user.permissions.canExportData) {
51
+ button.onclick = () => byRegion.export().catch((err) => showError(err.message));
52
+ }
53
+ ```
54
+
55
+ `export()` resolves once the file is made and the host has started saving it, with its link and
56
+ `rowCount`. The file is a CSV named after the app and the query unless you pass `filename`.
57
+
58
+ A reader can download a picture of the app, or of one block or container, as they see it:
59
+
60
+ ```js
61
+ await Anfra.render({ block: 'trend', format: 'png' }); // or 'pdf'; no node: the whole app
62
+ ```
63
+
64
+ It works only where the host turned rendering on (`mountDataApp({ render: true })`; `anfra serve`
65
+ does). Check `Anfra.features.render` and `Anfra.user.permissions.canExportData` before offering it, and
66
+ mark what shouldn't be drawn (the download button itself) `data-anfra-render="skip"`. Serve images
67
+ and web fonts with CORS: an image without it is left blank (listed in the result's `omitted`), a
68
+ font without it falls back.
69
+
47
70
  `Anfra.datasets` describes what there is to query. For TypeScript, `@holistics/anfra-sdk/ambient` declares the
48
71
  global.
49
72
 
@@ -81,20 +104,22 @@ const mounted = mountDataApp({
81
104
  baseHref: '/appserve/files/sales/', // where its relative URLs resolve
82
105
  datasets,
83
106
  user,
84
- backend: coreApiBackend(client, { datasets }), // queries and field suggestions, as core.query
107
+ backend: coreApiBackend(client, { datasets }), // queries, field suggestions and exports, as core ops
108
+ render: true, // let it save PNGs and PDFs of itself (off by default)
85
109
  });
86
- // mounted.setInspecting(true) to receive inspection snapshots; mounted.unmount() when done.
110
+ // mounted.setInspecting(true) to receive inspection snapshots; mounted.render({ format: 'pdf' }) to
111
+ // save a picture of the app (needs render: true); mounted.unmount() when done.
87
112
  ```
88
113
 
89
114
  `mountDataApp` puts the definition in an iframe with `sandbox="allow-scripts"`, as its `srcdoc`,
90
- with the frame bootstrap and the provisioned data ahead of it. The bootstrap's Backend forwards each call to the host over postMessage; the host answers it with the Backend it was given.
115
+ with the frame bootstrap and the provisioned data ahead of it. The bootstrap's Backend forwards each call to the host over postMessage; the host answers it with the Backend it was given. An export's file is saved from the host's page, by following its link: the frame can't download. A Backend without `exportQuery` makes a host that can't export, and its frames see `Anfra.features.export` false.
91
116
 
92
117
  ## The boundary
93
118
 
94
119
  A Data App definition can come from any author, often an agent, which may not think of security while building an analysis. So the SDK guards it for its author, and makes the safe way the easy one:
95
120
 
96
121
  - Its frame has an opaque origin. Its own requests to the API are cross-origin, which `anfra serve` refuses, and carry no credentials on a platform that has them.
97
- - The bridge answers only the Backend's two methods, `submitQuery` and `fieldSuggestions`. Nothing else of the API is reachable from a frame.
122
+ - The bridge answers only the Backend's methods, `submitQuery`, `fieldSuggestions` and `exportQuery`, and the two a rendering needs, the renderer's code and saving its file. Nothing else of the API is reachable from a frame.
98
123
  - `@holistics/anfra-sdk/app` imports only `common`: the script injected into a frame carries no API client. A test on the built frame script holds this.
99
124
 
100
125
  ## Development
package/dist/api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Client } from 'openapi-fetch';
2
- import { D as DatasetDescriptor, B as Backend, g as BackendQueryRequest } from './types-BlzJVcUl.js';
2
+ import { D as DatasetDescriptor, B as Backend, f as BackendExportRequest, h as BackendQueryRequest } from './types-IR4TNw-M.js';
3
3
  import { D as DataAppError } from './errors-CKvqVaqU.js';
4
4
 
5
5
  /**
@@ -1348,6 +1348,7 @@ type CoreClient = Client<paths>;
1348
1348
  declare function coreClient(baseUrl: string, fetchImpl?: typeof fetch): CoreClient;
1349
1349
 
1350
1350
  type QueryRunInput = components['schemas']['QueryRunInput'];
1351
+ type ExportInput = components['schemas']['ExportInput'];
1351
1352
  /** How many values a filter control is offered at most. */
1352
1353
  declare const SUGGESTION_LIMIT = 100;
1353
1354
  interface CoreApiBackendOptions {
@@ -1359,9 +1360,11 @@ interface CoreApiBackendOptions {
1359
1360
  }
1360
1361
  /** The core.query input a Backend request is: the AQL, its Query Input, its paging and timezone. */
1361
1362
  declare function toQueryInput(request: BackendQueryRequest): QueryRunInput;
1363
+ /** The core.query.export input an export request is: the query as a run sends it, and the file. */
1364
+ declare function toExportInput(request: BackendExportRequest): ExportInput;
1362
1365
  /**
1363
- * A Backend over the core API: a Data App's queries as `core.query`, and its field suggestions as
1364
- * one `core.query` too. The same on every host: `/api` on `anfra serve`, `/api/o/{org}` on
1366
+ * A Backend over the core API: a Data App's queries as `core.query`, its field suggestions as one
1367
+ * `core.query` too, and its exports as `core.query.export`. The same on every host: `/api` on `anfra serve`, `/api/o/{org}` on
1365
1368
  * anfra-cloud. A host's page uses it; a Data App's frame never does.
1366
1369
  */
1367
1370
  declare function coreApiBackend(base: string | CoreClient, options?: CoreApiBackendOptions): Backend;
@@ -1404,4 +1407,4 @@ interface ApiErrorBody {
1404
1407
  */
1405
1408
  declare function toSdkError(status: number, body: ApiErrorBody | undefined): DataAppError;
1406
1409
 
1407
- export { type ApiErrorBody, type CoreApiBackendOptions, type CoreClient, type LoadedDatasets, SUGGESTION_LIMIT, type components, coreApiBackend, coreClient, loadDatasets, type paths, toDescriptor, toQueryInput, toSdkError };
1410
+ export { type ApiErrorBody, type CoreApiBackendOptions, type CoreClient, type LoadedDatasets, SUGGESTION_LIMIT, type components, coreApiBackend, coreClient, loadDatasets, type paths, toDescriptor, toExportInput, toQueryInput, toSdkError };
package/dist/api.js CHANGED
@@ -2,8 +2,7 @@ import {
2
2
  PermissionError,
3
3
  QueryError,
4
4
  TransportError
5
- } from "./chunk-KCTLO3CO.js";
6
- import "./chunk-PKBMQBKP.js";
5
+ } from "./chunk-KBXITYQK.js";
7
6
 
8
7
  // src/api/client.ts
9
8
  import createClient from "openapi-fetch";
@@ -59,6 +58,18 @@ function toQueryInput(request) {
59
58
  ...request.timezone ? { timezone: request.timezone } : {}
60
59
  };
61
60
  }
61
+ function toExportInput(request) {
62
+ return {
63
+ query: request.aql,
64
+ dataset: request.dataset,
65
+ input: request.input,
66
+ ...request.timezone ? { timezone: request.timezone } : {},
67
+ // The server names its formats; one it does not know it refuses, with a message the author sees.
68
+ ...request.format ? { format: request.format } : {},
69
+ ...request.formatOptions ? { format_options: request.formatOptions } : {},
70
+ ...request.filename ? { filename: request.filename } : {}
71
+ };
72
+ }
62
73
  function toColumns(columns) {
63
74
  return columns.map((column) => {
64
75
  const { semantic_type: semanticType, arrow_type: arrowType, db_type: dbType } = column;
@@ -70,10 +81,10 @@ function toColumns(columns) {
70
81
  return { ...column, semantic_type: semanticType, arrow_type: arrowType, db_type: dbType };
71
82
  });
72
83
  }
73
- async function runQuery(client, body, signal) {
84
+ async function answer(call) {
74
85
  let result;
75
86
  try {
76
- result = await client.POST("/core.query", { body, signal });
87
+ result = await call();
77
88
  } catch (err) {
78
89
  if (isAbort(err)) throw err;
79
90
  throw new TransportError(`The server could not be reached: ${err.message}`);
@@ -82,6 +93,9 @@ async function runQuery(client, body, signal) {
82
93
  if (!data) throw toSdkError(response.status, error?.error);
83
94
  return data;
84
95
  }
96
+ function runQuery(client, body, signal) {
97
+ return answer(() => client.POST("/core.query", { body, signal }));
98
+ }
85
99
  function coreApiBackend(base, options = {}) {
86
100
  const client = typeof base === "string" ? coreClient(base) : base;
87
101
  return {
@@ -127,6 +141,17 @@ function coreApiBackend(base, options = {}) {
127
141
  page_size: SUGGESTION_LIMIT
128
142
  }, signal);
129
143
  return data.result.records.map((row) => row[0]).filter((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
144
+ },
145
+ /** An export as `core.query.export`: the server runs the query to completion, then answers its link. */
146
+ async exportQuery(request, signal) {
147
+ const link = await answer(() => client.POST("/core.query.export", { body: toExportInput(request), signal }));
148
+ return {
149
+ url: link.url,
150
+ filename: link.filename,
151
+ format: link.format,
152
+ rowCount: link.row_count,
153
+ expiresAt: new Date(link.expires_at)
154
+ };
130
155
  }
131
156
  };
132
157
  }
@@ -172,6 +197,7 @@ export {
172
197
  coreClient,
173
198
  loadDatasets,
174
199
  toDescriptor,
200
+ toExportInput,
175
201
  toQueryInput,
176
202
  toSdkError
177
203
  };
package/dist/app.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { L as Listener, a1 as Unsubscribe, D as DatasetDescriptor, E as EntityKind, j as Condition, w as FilterDeclaration, x as FilterValueType, B as Backend, k as ConditionValue, n as DateDrillDeclaration, o as DateGrain, v as FilterAggregation, l as ControlMappingJson, m as CrossFilterJson, X as SdkFeatures, Y as Selection, Z as SelectionCondition, V as Row, K as LocateKind, P as QueryDeclaration, U as QueryState, S as QueryResult, T as QuerySort, a as AppDeclaration, s as ExecuteOptions, t as ExecuteSummary, b as AppJson, I as InspectedApp, a2 as User, W as SdkEnvironment, J as InspectedStructure } from './types-BlzJVcUl.js';
2
- export { A as Aggregation, c as ArrowType, d as BackendCondition, e as BackendDateDrill, f as BackendFilter, g as BackendQueryRequest, h as BackendQueryResult, i as BackendSort, C as ColumnMeta, p as DateTransformation, q as EntityRef, r as EntityUsage, F as FILTER_AGGREGATIONS, u as FieldSuggestionsRequest, y as InspectedControl, z as InspectedError, G as InspectedQuery, H as InspectedSelection, M as LocateTarget, N as MappingJson, O as Operator, Q as QueryDebug, R as QueryInput, _ as SemanticType, $ as StructureKind, a0 as StructureNode } from './types-BlzJVcUl.js';
1
+ import { P as Listener, a9 as Unsubscribe, D as DatasetDescriptor, E as EntityKind, k as Condition, H as FilterDeclaration, I as FilterValueType, B as Backend, l as ConditionValue, o as DateDrillDeclaration, p as DateGrain, G as FilterAggregation, m as ControlMappingJson, n as CrossFilterJson, a3 as SdkFeatures, a4 as Selection, a5 as SelectionCondition, a1 as Row, x as ExportOptions, y as ExportResult, Q as LocateKind, V as QueryDeclaration, Z as QueryState, X as QueryResult, Y as QuerySort, a as AppDeclaration, t as ExecuteOptions, u as ExecuteSummary, b as AppJson, J as InspectedApp, aa as User, a2 as SdkEnvironment, $ as RenderRequest, a0 as Rendering, O as InspectedStructure } from './types-IR4TNw-M.js';
2
+ export { A as Aggregation, c as ArrowType, d as BackendCondition, e as BackendDateDrill, f as BackendExportRequest, g as BackendFilter, h as BackendQueryRequest, i as BackendQueryResult, j as BackendSort, C as ColumnMeta, q as DateTransformation, r as EntityRef, s as EntityUsage, v as ExportFormat, w as ExportFormatOptions, F as FILTER_AGGREGATIONS, z as FieldSuggestionsRequest, K as InspectedControl, L as InspectedError, M as InspectedQuery, N as InspectedSelection, R as LocateTarget, S as MappingJson, T as Operator, U as QueryDebug, W as QueryInput, _ as RenderFormat, a6 as SemanticType, a7 as StructureKind, a8 as StructureNode } from './types-IR4TNw-M.js';
3
3
  import { D as DataAppError } from './errors-CKvqVaqU.js';
4
4
  export { P as PermissionError, Q as QueryError, T as TransportError, V as ValidationError } from './errors-CKvqVaqU.js';
5
- export { B as BridgeError, a as BridgeMethod, F as FrameMessage, H as HostMessage, P as PROVISION_ELEMENT_ID, b as Provision } from './bridge-BKx7u5mX.js';
5
+ export { B as BridgeError, a as BridgeMethod, F as FrameMessage, H as HostMessage, P as PROVISION_ELEMENT_ID, b as Provision } from './bridge-BmbS9J4V.js';
6
6
 
7
7
  interface NotificationTarget {
8
8
  notifyChange(): void;
@@ -190,6 +190,8 @@ interface AppContext {
190
190
  select(query: Query, rows: readonly Row[], fields?: readonly string[]): void;
191
191
  /** Runs one more page for a query that already has a result. */
192
192
  fetchPage(query: Query): Promise<void>;
193
+ /** Exports a query's whole result, with what it was last run with. */
194
+ exportQuery(query: Query, options?: ExportOptions): Promise<ExportResult>;
193
195
  /** Brings an entity's marked elements into view; false when none is on the page. */
194
196
  locate(kind: LocateKind, name: string): boolean;
195
197
  }
@@ -254,6 +256,17 @@ declare class Query extends Observable {
254
256
  abort(): void;
255
257
  /** Fetches the next page and appends it. Rejects rather than swallowing, unlike `execute()`. */
256
258
  fetchMore(): Promise<void>;
259
+ /**
260
+ * Exports this query's whole result as a file, and has the host save it for the reader.
261
+ *
262
+ * Every row, not only the pages loaded, with what the result on screen was last run with: the
263
+ * mapped controls, the selection, the sort, the timezone. A control the reader changed but has
264
+ * not applied is not in it. Resolves once the file is made and its download has started, with
265
+ * its link and row count; rejects with a `DataAppError` naming this query, and changes nothing on
266
+ * the query either way. Show the button when `Anfra.features.export` and
267
+ * `Anfra.user.permissions.canExportData` are both true.
268
+ */
269
+ export(options?: ExportOptions): Promise<ExportResult>;
257
270
  /**
258
271
  * Cross-filters this query's targets by the rows the reader picked.
259
272
  *
@@ -389,6 +402,11 @@ declare class App extends Observable implements AppContext {
389
402
  abort(): void;
390
403
  /** @internal Backs `query.fetchMore()`. Rejects, so an explicit page request reports its failure. */
391
404
  fetchPage(query: Query): Promise<void>;
405
+ /**
406
+ * @internal Backs `query.export()`. Built from the applied state, as `fetchPage` is, so the file
407
+ * holds what the reader is looking at, every row of it. Touches nothing on the query.
408
+ */
409
+ exportQuery(query: Query, options?: ExportOptions): Promise<ExportResult>;
392
410
  /**
393
411
  * The declaration and nothing else — no datasets, no backend, no reader state. This is the
394
412
  * shape a persisted Data App record will take, which is why mapping edges are already generic.
@@ -451,9 +469,22 @@ declare class Sdk {
451
469
  readonly user: Readonly<User>;
452
470
  private readonly indices;
453
471
  private readonly backend;
454
- private readonly features;
472
+ /**
473
+ * What the host can do beyond queries. `export` says whether `query.export()` can work here, and
474
+ * `render` whether `render()` can: show either's button only when it and
475
+ * `user.permissions.canExportData` are both true.
476
+ */
477
+ readonly features: Readonly<SdkFeatures>;
478
+ private readonly renderHook?;
455
479
  constructor(environment: SdkEnvironment);
456
480
  createApp(declaration?: AppDeclaration): App;
481
+ /**
482
+ * Render the whole app, or one block or container of its structure by id, as a PNG or a PDF, and
483
+ * have the host save it for the reader. What is drawn is what the page shows now: `await
484
+ * app.execute()` first for fresh results. Elements marked `data-anfra-render="skip"` are left out.
485
+ * Rejects with a `ValidationError` naming what to fix.
486
+ */
487
+ render(request: RenderRequest): Promise<Rendering>;
457
488
  /**
458
489
  * Every app this instance has built, for a host rendering an inspection panel.
459
490
  *
@@ -524,4 +555,4 @@ declare function buildStructure(doc: Document, apps: readonly App[]): BuiltStruc
524
555
  /** Provision the SDK in `win` from its host, and install it. Returns once `Anfra` exists. */
525
556
  declare function bootstrap(win: Window): void;
526
557
 
527
- export { App, AppDeclaration, AppJson, Backend, Condition, ConditionValue, Control, ControlMappingJson, CrossFilter, CrossFilterJson, DataAppError, DatasetDescriptor, DateDrillControl, DateDrillDeclaration, DateGrain, type Edge, EntityKind, ExecuteOptions, ExecuteSummary, Filter, FilterAggregation, FilterDeclaration, FilterValueType, InspectedApp, InspectedStructure, Listener, LocateKind, Mapping, OVERLAY_ID, Query, QueryDeclaration, QueryResult, QuerySort, QueryState, Row, SANDBOX_GLOBAL, ATTR as STRUCTURE_ATTRIBUTES, Sdk, SdkEnvironment, SdkFeatures, Selection, SelectionCondition, Unsubscribe, User, VERSION, bootstrap, buildStructure, createSdk, installSandbox, parseRef };
558
+ export { App, AppDeclaration, AppJson, Backend, Condition, ConditionValue, Control, ControlMappingJson, CrossFilter, CrossFilterJson, DataAppError, DatasetDescriptor, DateDrillControl, DateDrillDeclaration, DateGrain, type Edge, EntityKind, ExecuteOptions, ExecuteSummary, ExportOptions, ExportResult, Filter, FilterAggregation, FilterDeclaration, FilterValueType, InspectedApp, InspectedStructure, Listener, LocateKind, Mapping, OVERLAY_ID, Query, QueryDeclaration, QueryResult, QuerySort, QueryState, RenderRequest, Rendering, Row, SANDBOX_GLOBAL, ATTR as STRUCTURE_ATTRIBUTES, Sdk, SdkEnvironment, SdkFeatures, Selection, SelectionCondition, Unsubscribe, User, VERSION, bootstrap, buildStructure, createSdk, installSandbox, parseRef };
package/dist/app.js CHANGED
@@ -9,11 +9,9 @@ import {
9
9
  PermissionError,
10
10
  QueryError,
11
11
  TransportError,
12
- ValidationError
13
- } from "./chunk-KCTLO3CO.js";
14
- import {
12
+ ValidationError,
15
13
  __publicField
16
- } from "./chunk-PKBMQBKP.js";
14
+ } from "./chunk-KBXITYQK.js";
17
15
 
18
16
  // src/app/observable.ts
19
17
  var Observable = class {
@@ -169,6 +167,19 @@ var Query = class extends Observable {
169
167
  this._page += 1;
170
168
  await this.app.fetchPage(this);
171
169
  }
170
+ /**
171
+ * Exports this query's whole result as a file, and has the host save it for the reader.
172
+ *
173
+ * Every row, not only the pages loaded, with what the result on screen was last run with: the
174
+ * mapped controls, the selection, the sort, the timezone. A control the reader changed but has
175
+ * not applied is not in it. Resolves once the file is made and its download has started, with
176
+ * its link and row count; rejects with a `DataAppError` naming this query, and changes nothing on
177
+ * the query either way. Show the button when `Anfra.features.export` and
178
+ * `Anfra.user.permissions.canExportData` are both true.
179
+ */
180
+ export(options = {}) {
181
+ return this.app.exportQuery(this, options);
182
+ }
172
183
  /**
173
184
  * Cross-filters this query's targets by the rows the reader picked.
174
185
  *
@@ -624,7 +635,7 @@ function toResult(query, data) {
624
635
  ...data.debug ? { debug: data.debug } : {}
625
636
  };
626
637
  }
627
- function buildRequest(query, mappings, selection, context) {
638
+ function buildQuery(query, mappings, selection, context) {
628
639
  const mappedFilters = mappings.map((mapping) => toFilter(mapping, mapping.from.appliedCondition)).filter((filter) => filter !== void 0);
629
640
  const dateDrills = mappings.map((mapping) => toDateDrill(mapping, mapping.from.appliedCondition)).filter((drill) => drill !== void 0);
630
641
  const selectionFilters = selection.conditions.map((condition) => ({
@@ -642,14 +653,33 @@ function buildRequest(query, mappings, selection, context) {
642
653
  sorts: query.sort.map((sort) => ({ field: sort.field, direction: sort.direction ?? "asc" })),
643
654
  dateDrills
644
655
  },
645
- // Omitted for a query that declares no page size, so the backend returns every row.
646
- ...query.pageSize !== void 0 ? { page: query.page, pageSize: query.pageSize } : {},
647
656
  // Omitted entirely when the app declares none, so the backend applies its own default rather
648
657
  // than the SDK guessing the browser's.
649
- ...context.timezone ? { timezone: context.timezone } : {},
658
+ ...context.timezone ? { timezone: context.timezone } : {}
659
+ };
660
+ }
661
+ function buildRequest(query, mappings, selection, context) {
662
+ return {
663
+ ...buildQuery(query, mappings, selection, context),
664
+ // Omitted for a query that declares no page size, so the backend returns every row.
665
+ ...query.pageSize !== void 0 ? { page: query.page, pageSize: query.pageSize } : {},
650
666
  ...context.bustCache ? { bustCache: true } : {}
651
667
  };
652
668
  }
669
+ function defaultExportFilename(query, title) {
670
+ const safeTitle = title?.replace(/[/\\]/g, "-").replace(/\p{Cc}/gu, "").trim();
671
+ return safeTitle ? `${safeTitle} - ${query.name}` : query.name;
672
+ }
673
+ function buildExportRequest(query, mappings, selection, context, options) {
674
+ const format2 = options.format ?? "csv";
675
+ const formatOptions = format2 === "csv" ? { bom: true, ...options.formatOptions } : options.formatOptions;
676
+ return {
677
+ ...buildQuery(query, mappings, selection, context),
678
+ format: format2,
679
+ ...formatOptions ? { formatOptions } : {},
680
+ filename: options.filename?.trim() || defaultExportFilename(query, context.title)
681
+ };
682
+ }
653
683
  async function runQuery(query, mappings, selection, context, signal) {
654
684
  const request = buildRequest(query, mappings, selection, context);
655
685
  const data = await context.backend.submitQuery(request, signal);
@@ -1204,6 +1234,14 @@ function toDataAppError(err, entity) {
1204
1234
  if (err instanceof DataAppError) return err;
1205
1235
  return new QueryError(err?.message ?? "The query failed.", entity, err);
1206
1236
  }
1237
+ function toExportError(err, entity) {
1238
+ if (err instanceof DataAppError && err.entity) return err;
1239
+ const message = err?.message || "The export failed.";
1240
+ if (err instanceof PermissionError) return new PermissionError(message, entity);
1241
+ if (err instanceof TransportError) return new TransportError(message, err.status, entity);
1242
+ if (err instanceof ValidationError) return new ValidationError(message, entity);
1243
+ return new QueryError(message, entity, err);
1244
+ }
1207
1245
  var App = class extends Observable {
1208
1246
  constructor(declaration, datasets, backend, features) {
1209
1247
  super();
@@ -1547,6 +1585,32 @@ var App = class extends Observable {
1547
1585
  if (query.controller === controller) query.controller = void 0;
1548
1586
  }
1549
1587
  }
1588
+ /**
1589
+ * @internal Backs `query.export()`. Built from the applied state, as `fetchPage` is, so the file
1590
+ * holds what the reader is looking at, every row of it. Touches nothing on the query.
1591
+ */
1592
+ async exportQuery(query, options = {}) {
1593
+ const { backend } = this;
1594
+ if (this.features?.export === false || !backend.exportQuery) {
1595
+ throw new TransportError("This host cannot make exports.", void 0, query.name);
1596
+ }
1597
+ const request = buildExportRequest(
1598
+ query,
1599
+ this.mappingsTo(query.name),
1600
+ {
1601
+ conditions: this.selectionConditionsFor(query.name, "applied"),
1602
+ expression: this.selectionExpressionFor(query.name, "applied")
1603
+ },
1604
+ { backend, timezone: this._timezone, title: this.title },
1605
+ options
1606
+ );
1607
+ try {
1608
+ return await backend.exportQuery(request, options.signal ?? new AbortController().signal);
1609
+ } catch (err) {
1610
+ if (isAbort(err)) throw err;
1611
+ throw toExportError(err, query.name);
1612
+ }
1613
+ }
1550
1614
  /* ----------------------------------------------------------------
1551
1615
  Serialisation
1552
1616
  ---------------------------------------------------------------- */
@@ -1661,7 +1725,7 @@ var App = class extends Observable {
1661
1725
  };
1662
1726
 
1663
1727
  // package.json
1664
- var version = "0.7.1";
1728
+ var version = "0.8.0";
1665
1729
 
1666
1730
  // src/app/sdk.ts
1667
1731
  var VERSION = version;
@@ -1683,7 +1747,13 @@ var Sdk = class {
1683
1747
  __publicField(this, "user");
1684
1748
  __publicField(this, "indices");
1685
1749
  __publicField(this, "backend");
1750
+ /**
1751
+ * What the host can do beyond queries. `export` says whether `query.export()` can work here, and
1752
+ * `render` whether `render()` can: show either's button only when it and
1753
+ * `user.permissions.canExportData` are both true.
1754
+ */
1686
1755
  __publicField(this, "features");
1756
+ __publicField(this, "renderHook");
1687
1757
  if (!environment?.datasets) {
1688
1758
  throw new ValidationError(
1689
1759
  "No datasets provided. The host must call createSdk({ datasets }) before an app can be declared."
@@ -1711,7 +1781,14 @@ var Sdk = class {
1711
1781
  ])
1712
1782
  );
1713
1783
  this.backend = environment.backend;
1714
- this.features = Object.freeze({ ...environment.features });
1784
+ this.renderHook = environment.render;
1785
+ this.features = Object.freeze({
1786
+ ...environment.features,
1787
+ // Known from the Backend itself when the host does not say. In a frame the bridge's Backend
1788
+ // has every method, so there it is the host's provision that says.
1789
+ export: environment.features?.export ?? typeof environment.backend.exportQuery === "function",
1790
+ render: environment.features?.render ?? typeof environment.render === "function"
1791
+ });
1715
1792
  }
1716
1793
  createApp(declaration = {}) {
1717
1794
  const app = new App(declaration, this.indices, this.backend, this.features);
@@ -1719,6 +1796,18 @@ var Sdk = class {
1719
1796
  this._apps.push(app);
1720
1797
  return app;
1721
1798
  }
1799
+ /**
1800
+ * Render the whole app, or one block or container of its structure by id, as a PNG or a PDF, and
1801
+ * have the host save it for the reader. What is drawn is what the page shows now: `await
1802
+ * app.execute()` first for fresh results. Elements marked `data-anfra-render="skip"` are left out.
1803
+ * Rejects with a `ValidationError` naming what to fix.
1804
+ */
1805
+ render(request) {
1806
+ if (!this.renderHook || this.features.render === false) {
1807
+ return Promise.reject(new ValidationError("This host does not render Data Apps: check Anfra.features.render first."));
1808
+ }
1809
+ return this.renderHook(request);
1810
+ }
1722
1811
  /**
1723
1812
  * Every app this instance has built, for a host rendering an inspection panel.
1724
1813
  *
@@ -1780,6 +1869,92 @@ function startPick(doc, handlers) {
1780
1869
  };
1781
1870
  }
1782
1871
 
1872
+ // src/render/types.ts
1873
+ var RENDERER_GLOBAL = "__anfraRenderer";
1874
+
1875
+ // src/app/render.ts
1876
+ var SKIP_ATTRIBUTE = "data-anfra-render";
1877
+ var EXTENSIONS = { png: "png", pdf: "pdf" };
1878
+ var SETTLE_MS = 1e4;
1879
+ function filenameFor(request, doc, apps, element) {
1880
+ const ext = EXTENSIONS[request.format];
1881
+ let name = request.filename?.trim();
1882
+ if (!name) {
1883
+ const title = apps.find((app) => app.title)?.title || doc.title.trim() || "Data App";
1884
+ const node = element && (element.getAttribute(ATTR.label) || request.block || request.container);
1885
+ name = node ? `${title} - ${node}` : title;
1886
+ }
1887
+ name = name.replace(/[/\\]/g, "-").replace(/\p{Cc}/gu, "").trim() || "Data App";
1888
+ return name.toLowerCase().endsWith(`.${ext}`) ? name : `${name}.${ext}`;
1889
+ }
1890
+ function targetOf(request, doc) {
1891
+ if (request.block !== void 0 && request.container !== void 0) {
1892
+ throw new ValidationError("Render a block or a container, not both.", request.block);
1893
+ }
1894
+ if (request.format !== "png" && request.format !== "pdf") {
1895
+ throw new ValidationError(`Can't render as '${String(request.format)}': the formats are 'png' and 'pdf'.`);
1896
+ }
1897
+ const { pixelRatio } = request;
1898
+ if (pixelRatio !== void 0 && !(Number.isFinite(pixelRatio) && pixelRatio > 0)) {
1899
+ throw new ValidationError(`pixelRatio ${String(pixelRatio)} is not a positive number.`);
1900
+ }
1901
+ const kind = request.block !== void 0 ? "block" : request.container !== void 0 ? "container" : void 0;
1902
+ if (!kind) return { element: null };
1903
+ const id = request.block ?? request.container;
1904
+ const element = [...doc.querySelectorAll(`[${ATTR[kind]}]`)].find((el) => el.getAttribute(ATTR[kind]) === id);
1905
+ if (!element) throw new ValidationError(`No element is marked ${ATTR[kind]}="${id}" on the page.`, id);
1906
+ return { element, entity: id };
1907
+ }
1908
+ function skip(element) {
1909
+ return element.id === OVERLAY_ID || element.getAttribute(SKIP_ATTRIBUTE) === "skip";
1910
+ }
1911
+ function createRender(win, deps) {
1912
+ let loading;
1913
+ const load = () => {
1914
+ const ready = win[RENDERER_GLOBAL];
1915
+ if (ready) return Promise.resolve(ready);
1916
+ loading ?? (loading = deps.ask.renderer().then((code) => {
1917
+ const script = win.document.createElement("script");
1918
+ script.textContent = code;
1919
+ (win.document.head ?? win.document.documentElement).appendChild(script);
1920
+ script.remove();
1921
+ const installed = win[RENDERER_GLOBAL];
1922
+ if (!installed) throw new ValidationError("The host sent a renderer that did not install.");
1923
+ return installed;
1924
+ }).catch((err) => {
1925
+ loading = void 0;
1926
+ throw err;
1927
+ }));
1928
+ return loading;
1929
+ };
1930
+ return async (request) => {
1931
+ const doc = win.document;
1932
+ const { element, entity } = targetOf(request ?? {}, doc);
1933
+ const filename = filenameFor(request, doc, deps.apps(), element);
1934
+ try {
1935
+ const renderer = await load();
1936
+ const drawing = await renderer.draw(element, { format: request.format, pixelRatio: request.pixelRatio, skip });
1937
+ await deps.ask.saveFile({ blob: drawing.blob, filename });
1938
+ return { filename, format: request.format, ...drawing };
1939
+ } catch (err) {
1940
+ if (err instanceof ValidationError && err.entity) throw err;
1941
+ throw new ValidationError(err?.message || "The rendering failed.", entity);
1942
+ }
1943
+ };
1944
+ }
1945
+ async function settle(win, apps, timeout = SETTLE_MS) {
1946
+ const busy = () => apps().some((app) => Object.values(app.queries).some((query) => query.state === "executing"));
1947
+ const deadline = Date.now() + timeout;
1948
+ while (busy() && Date.now() < deadline) {
1949
+ await new Promise((resolve) => {
1950
+ win.setTimeout(resolve, 100);
1951
+ });
1952
+ }
1953
+ await new Promise((resolve) => {
1954
+ win.requestAnimationFrame(() => resolve(void 0));
1955
+ });
1956
+ }
1957
+
1783
1958
  // src/app/bootstrap.ts
1784
1959
  function abortError() {
1785
1960
  const err = new Error("Aborted");
@@ -1792,6 +1967,8 @@ function toError(error) {
1792
1967
  return new PermissionError(error.message);
1793
1968
  case "TransportError":
1794
1969
  return new TransportError(error.message);
1970
+ case "ValidationError":
1971
+ return new ValidationError(error.message);
1795
1972
  default:
1796
1973
  return new QueryError(error?.message || "The query failed.");
1797
1974
  }
@@ -1829,6 +2006,14 @@ function bootstrap(win) {
1829
2006
  case "anfra:pick-watch":
1830
2007
  setPicking(message.on);
1831
2008
  return;
2009
+ case "anfra:render": {
2010
+ const { id } = message;
2011
+ settle(win, () => instance.apps).then(() => instance.render(message.request)).then(
2012
+ (result) => post({ type: "anfra:rendered", id, ok: true, result }),
2013
+ (err) => post({ type: "anfra:rendered", id, ok: false, error: { name: err?.name ?? "ValidationError", message: err?.message ?? "The rendering failed." } })
2014
+ );
2015
+ return;
2016
+ }
1832
2017
  default:
1833
2018
  }
1834
2019
  });
@@ -1853,9 +2038,28 @@ function bootstrap(win) {
1853
2038
  if (result.debug?.executedAt) result.debug.executedAt = new Date(result.debug.executedAt);
1854
2039
  return result;
1855
2040
  }),
1856
- fieldSuggestions: (request, signal) => call("fieldSuggestions", request, signal)
2041
+ fieldSuggestions: (request, signal) => call("fieldSuggestions", request, signal),
2042
+ exportQuery: (request, signal) => call("exportQuery", request, signal).then((result) => {
2043
+ result.expiresAt = new Date(result.expiresAt);
2044
+ return result;
2045
+ })
1857
2046
  };
1858
- const instance = createSdk({ datasets: provision.datasets, user: provision.user, backend });
2047
+ const render = createRender(win, {
2048
+ ask: {
2049
+ renderer: () => call("renderer", null, new AbortController().signal),
2050
+ saveFile: (request) => call("saveFile", request, new AbortController().signal)
2051
+ },
2052
+ apps: () => instance.apps
2053
+ });
2054
+ const instance = createSdk({
2055
+ datasets: provision.datasets,
2056
+ user: provision.user,
2057
+ backend,
2058
+ render,
2059
+ // A host older than `features` provisions none: say it can neither export nor render, rather
2060
+ // than let the bridge's Backend, which has every method, claim an export.
2061
+ features: { export: false, render: false, ...provision.features }
2062
+ });
1859
2063
  const doc = win.document;
1860
2064
  let structure;
1861
2065
  let lastStructureJson = "";
@@ -1,4 +1,4 @@
1
- import { g as BackendQueryRequest, u as FieldSuggestionsRequest, I as InspectedApp, J as InspectedStructure, M as LocateTarget, D as DatasetDescriptor, a2 as User } from './types-BlzJVcUl.js';
1
+ import { h as BackendQueryRequest, z as FieldSuggestionsRequest, f as BackendExportRequest, a0 as Rendering, J as InspectedApp, O as InspectedStructure, R as LocateTarget, $ as RenderRequest, D as DatasetDescriptor, aa as User, a3 as SdkFeatures } from './types-IR4TNw-M.js';
2
2
 
3
3
  /**
4
4
  * The bridge: the postMessage protocol between a Data App's frame (`app`'s bootstrap) and the
@@ -6,12 +6,22 @@ import { g as BackendQueryRequest, u as FieldSuggestionsRequest, I as InspectedA
6
6
  * makes to its Backend crosses here, and the host answers it.
7
7
  */
8
8
 
9
- /** The Backend methods a frame may call: the bridge serves these and nothing else. */
10
- type BridgeMethod = 'submitQuery' | 'fieldSuggestions';
9
+ /**
10
+ * What a frame may ask its host: the Backend's methods, and the two a rendering needs (the
11
+ * renderer's code, and saving a file). The bridge serves these and nothing else.
12
+ */
13
+ type BridgeMethod = 'submitQuery' | 'fieldSuggestions' | 'exportQuery' | 'renderer' | 'saveFile';
14
+ /** A file the frame made, for the host to save for the reader: the frame cannot download. */
15
+ interface SaveFileRequest {
16
+ blob: Blob;
17
+ filename: string;
18
+ }
11
19
  /** What the host provisions a frame with, as JSON in `<script type="application/json" id="anfra-provision">`. */
12
20
  interface Provision {
13
21
  datasets: Record<string, DatasetDescriptor>;
14
22
  user: User;
23
+ /** What the host can do beyond queries: exports, rendering. Absent from a host older than this field. */
24
+ features?: SdkFeatures;
15
25
  /** The host page's origin: where the frame posts, so nothing else can read its messages. */
16
26
  hostOrigin: string;
17
27
  }
@@ -29,6 +39,37 @@ type FrameMessage = {
29
39
  id: number;
30
40
  method: 'fieldSuggestions';
31
41
  request: FieldSuggestionsRequest;
42
+ }
43
+ /** The host makes the file, starts saving it for the reader, then answers its link. */
44
+ | {
45
+ type: 'anfra:request';
46
+ id: number;
47
+ method: 'exportQuery';
48
+ request: BackendExportRequest;
49
+ }
50
+ /** The renderer's code, asked for on the frame's first rendering; answered as a string. */
51
+ | {
52
+ type: 'anfra:request';
53
+ id: number;
54
+ method: 'renderer';
55
+ request: null;
56
+ } | {
57
+ type: 'anfra:request';
58
+ id: number;
59
+ method: 'saveFile';
60
+ request: SaveFileRequest;
61
+ }
62
+ /** The answer to the host's `anfra:render`, by its id. */
63
+ | {
64
+ type: 'anfra:rendered';
65
+ id: number;
66
+ ok: true;
67
+ result: Rendering;
68
+ } | {
69
+ type: 'anfra:rendered';
70
+ id: number;
71
+ ok: false;
72
+ error: BridgeError;
32
73
  } | {
33
74
  type: 'anfra:cancel';
34
75
  id: number;
@@ -76,6 +117,12 @@ type HostMessage = {
76
117
  | {
77
118
  type: 'anfra:pick-watch';
78
119
  on: boolean;
120
+ }
121
+ /** Render the app or one node, once its queries settle, and save it; the frame answers `anfra:rendered`. */
122
+ | {
123
+ type: 'anfra:render';
124
+ id: number;
125
+ request: RenderRequest;
79
126
  };
80
127
 
81
128
  export { type BridgeError as B, type FrameMessage as F, type HostMessage as H, PROVISION_ELEMENT_ID as P, type BridgeMethod as a, type Provision as b };