@jitto/analyze-embed 0.0.0-stage → 0.2.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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The public contract of `@jitto/analyze-embed` v1: everything a host app passes to the SDK and everything
3
+ * the SDK passes back. Both wrappers, `mountJittoDashboard()` (plain JavaScript) and
4
+ * `<JittoDashboard>` (React), are typed from this file and nothing else, so they cannot drift apart.
5
+ *
6
+ * This file is reviewed like an API: a change here is a change to the contract.
7
+ * The wire format `fetchToken` receives from `POST /api/v1/embed-tokens` is the embed API's `EmbedToken`.
8
+ * `fetchToken` calls it with the signed-in user's own access token; no shared account is involved.
9
+ *
10
+ * Version policy: package major N targets embed API `/api/vN/`. The SDK sends no version header.
11
+ *
12
+ * Names here are Jitto's only. How a dashboard is rendered inside the frame is internal to Jitto and
13
+ * never appears in a type, an option, an event or a console message of a production build.
14
+ */
15
+ /**
16
+ * What `fetchToken` resolves with, built from the answer of `POST /api/v1/embed-tokens`.
17
+ * Field names are the SDK's (camelCase); `fetchToken` maps `dashboard_id`, `embed_url`, `expires_in`.
18
+ */
19
+ export interface EmbedTokenResponse {
20
+ /**
21
+ * The dashboard token. Opaque: not a readable JWT, and nothing in it, decoded, names an id, a filter or a
22
+ * claim. Like anything in a browser, the user can read it, so it holds nothing the user may not see. The SDK
23
+ * never decodes, logs or stores it; it times refreshes from `expiresIn` only. A missing or empty `token` is
24
+ * refused (`failed`, reason `token`).
25
+ */
26
+ token: string;
27
+ /** Must equal the `dashboardId` the SDK asked for, or the answer is refused (`failed`, reason `token`). */
28
+ dashboardId: string;
29
+ /**
30
+ * Where the dashboard is shown, unchanged from the API's `embed_url`: the tenant's own Jitto host followed by
31
+ * `/embed`. The SDK accepts it only when it is exactly `embedOrigin` + `/embed`: no user, password, other path,
32
+ * query or fragment. Any other value is refused before the token leaves the page (`failed`, reason `token`).
33
+ */
34
+ embedUrl: string;
35
+ /**
36
+ * Seconds the token lives. Absent means 300. The SDK refreshes at 80 % of it, and at most about 24.8 days after it
37
+ * arrived (the longest delay a browser timer holds). When present it must be a finite number above 0; `0`, a
38
+ * negative number, `null`, `NaN`, `Infinity` or a string is refused (`failed`, reason `token`, no retry).
39
+ */
40
+ expiresIn?: number;
41
+ }
42
+ /**
43
+ * Gets a dashboard token for the signed-in user. It calls `POST /api/v1/embed-tokens` with
44
+ * `Authorization: Bearer <the user's own access token>` and the body `{dashboard_id}` only, never a user,
45
+ * groups or claims. Called on mount and before every expiry. The SDK gives it a
46
+ * deadline (`tokenDeadlineMs`) and aborts `signal` when the deadline passes or the dashboard unmounts.
47
+ * Reject with a `TokenRequestError` to pass the API's HTTP status and problem `code` on. On success,
48
+ * resolve with the API's answer, field names mapped and values unchanged: the SDK checks `token`, `dashboardId` and `embedUrl` itself
49
+ * and refuses a wrong answer with reason `token`. Do not reject a malformed 200 yourself with its status:
50
+ * a status the SDK does not list maps to `network`.
51
+ */
52
+ export type FetchToken = (dashboardId: string, signal: AbortSignal) => Promise<EmbedTokenResponse>;
53
+ /** Optional detail a `FetchToken` may attach to its rejection, so `failed` can say why. */
54
+ export interface TokenRequestError extends Error {
55
+ /** The HTTP status the embed API answered with, if any. */
56
+ status?: number;
57
+ /** The embed API's problem `code`, if any. */
58
+ code?: ProblemCode | string;
59
+ /** The embed API's `correlation_id`, if any. */
60
+ correlationId?: string;
61
+ /** The `Retry-After` the embed API sent with a 429, in seconds. The SDK waits at least this long before retrying. */
62
+ retryAfterSeconds?: number;
63
+ }
64
+ /**
65
+ * Theme tokens: colours, font and light or dark. Jitto keeps the layout; there is no CSS hook.
66
+ * Colours are CSS colour strings (`#1a73e8`, `rgb(...)`); anything else is ignored with a console warning.
67
+ */
68
+ export interface ThemeTokens {
69
+ mode?: 'light' | 'dark';
70
+ /** Buttons, selected states, links. */
71
+ colorPrimary?: string;
72
+ /** The dashboard's page background. */
73
+ colorBackground?: string;
74
+ /** Chart and filter card background. */
75
+ colorSurface?: string;
76
+ /** Body text. */
77
+ colorText?: string;
78
+ /** Borders and dividers. */
79
+ colorBorder?: string;
80
+ /** A CSS `font-family` list. The font must already be loadable by the dashboard host. */
81
+ fontFamily?: string;
82
+ }
83
+ /** A date range, ISO 8601 dates (`2026-06-30`) or Jitto's relative forms (`Last week`). */
84
+ export interface DateRange {
85
+ from: string;
86
+ to: string;
87
+ }
88
+ /** One filter's value. A list selects several values; `null` clears the filter. */
89
+ export type FilterValue = string | number | boolean | ReadonlyArray<string | number> | DateRange | null;
90
+ /**
91
+ * Filters by name, as the dashboard's filter bar names them (for example `Currency`, `Ccy`, `Report date`).
92
+ * A name matches only when it equals a filter-bar name of the dashboard definition exactly, case included.
93
+ * Filters narrow what the user sees. They are not a security boundary: the tenant's row rules are.
94
+ */
95
+ export type Filters = Readonly<Record<string, FilterValue>>;
96
+ /**
97
+ * Why the dashboard refused host filters:
98
+ * - `unknown_filter`: a name is not a filter-bar name of the dashboard.
99
+ * - `invalid_filter_value`: a value has the wrong kind for its filter (a `DateRange` for a value filter,
100
+ * a plain value or list for a date filter, a list for a single-choice filter, anything but a list of exactly
101
+ * two numbers or `null` for a numeric-range filter).
102
+ * A value that is the right kind but matches no row is not an error: the charts show zero rows.
103
+ */
104
+ export declare const FILTER_ERROR_CODES: readonly ["unknown_filter", "invalid_filter_value"];
105
+ export type FilterErrorCode = (typeof FILTER_ERROR_CODES)[number];
106
+ /**
107
+ * What `setFilters` rejects with, and what `filterRejected` carries, when the dashboard refuses host
108
+ * filters. Nothing was applied: the filters in effect before the call stay in effect.
109
+ */
110
+ export interface FilterError extends Error {
111
+ readonly name: 'FilterError';
112
+ readonly code: FilterErrorCode;
113
+ readonly dashboardId: string;
114
+ /**
115
+ * The names the host passed that caused the refusal, in the order the host passed them. When a call has
116
+ * both an unknown name and a wrong value, `code` is `unknown_filter` and this still lists every refused
117
+ * name of either reason; `knownFilterNames` tells them apart.
118
+ */
119
+ readonly filterNames: readonly string[];
120
+ /** Every filter-bar name the dashboard has, so the host can log or correct the call. */
121
+ readonly knownFilterNames: readonly string[];
122
+ }
123
+ /**
124
+ * Why a dashboard did not load, or stopped working. Every failure maps to one of these four, whether it
125
+ * came from the token request, the network, or the dashboard frame itself; the SDK never passes on an
126
+ * error message from inside the frame.
127
+ */
128
+ export type FailureReason = 'token' | 'permission' | 'network' | 'not_found';
129
+ /** The embed API's stable problem codes (openapi/embed.yaml `ProblemCode`). */
130
+ export type ProblemCode = 'invalid_request' | 'unauthenticated' | 'not_permitted' | 'not_embeddable' | 'origin_not_allowed' | 'unknown_dashboard' | 'row_rules_incomplete' | 'rate_limited' | 'internal_error' | 'upstream_unavailable' | 'embedding_not_configured' | 'no_group';
131
+ /**
132
+ * How a problem code becomes a failure reason. Exhaustive: adding a code to `ProblemCode` without a
133
+ * reason here fails the typecheck. A code the SDK does not know maps by HTTP status
134
+ * (`FAILURE_REASON_BY_STATUS`). Both maps must equal openapi/embed.yaml's `x-failure-reason` and
135
+ * `x-failure-reason-by-status`; `scripts/check-contract-sync.mjs` compares them.
136
+ */
137
+ export declare const FAILURE_REASON_BY_CODE: {
138
+ readonly invalid_request: "network";
139
+ readonly unauthenticated: "token";
140
+ readonly not_permitted: "permission";
141
+ readonly not_embeddable: "permission";
142
+ readonly origin_not_allowed: "permission";
143
+ readonly unknown_dashboard: "not_found";
144
+ readonly row_rules_incomplete: "permission";
145
+ readonly rate_limited: "token";
146
+ readonly internal_error: "network";
147
+ readonly upstream_unavailable: "network";
148
+ readonly embedding_not_configured: "permission";
149
+ readonly no_group: "permission";
150
+ };
151
+ /**
152
+ * The fallback for a code the SDK does not know, by HTTP status. Any status not listed, and a rejection
153
+ * with no status (other than a deadline, which is `token`), maps to `default`.
154
+ */
155
+ export declare const FAILURE_REASON_BY_STATUS: {
156
+ readonly 401: "token";
157
+ readonly 403: "permission";
158
+ readonly 404: "not_found";
159
+ readonly 429: "token";
160
+ readonly default: "network";
161
+ };
162
+ /**
163
+ * Token request outcomes the SDK retries: no answer within `tokenDeadlineMs`, a network failure, and HTTP
164
+ * 429, 500 and 502. Every other failure (400, 401, 403, 404, 422, 503, an answer without a token, a token
165
+ * for another dashboard, an `embedUrl` that is not `embedOrigin` + `/embed`) ends the mount at once with
166
+ * `failed`. A refresh never outlasts the current token: see `retryDelaysMs`.
167
+ */
168
+ export declare const RETRYABLE_STATUSES: readonly [429, 500, 502];
169
+ /** Each event's payload. */
170
+ export interface EmbedEventMap {
171
+ /** The dashboard rendered for the first time after a mount. */
172
+ loaded: {
173
+ dashboardId: string;
174
+ durationMs: number;
175
+ };
176
+ /**
177
+ * The dashboard could not load, a token refresh failed for good, or a `setFilters` reload did not render
178
+ * within `loadDeadlineMs` (reason `network`). Fires at most once per mount, and
179
+ * after it the mount is finished: no more token requests or events. The SDK removes the frame and shows a
180
+ * short Jitto message in its place. The SDK never remounts on its own; the host may mount again.
181
+ */
182
+ failed: {
183
+ dashboardId: string;
184
+ reason: FailureReason;
185
+ message: string;
186
+ status?: number;
187
+ /**
188
+ * The embed API's problem code, or a `FilterErrorCode` when the mount's own `filters` were refused.
189
+ * `string & {}` keeps the named codes visible to editors and type checks; a bare `string` would absorb them.
190
+ */
191
+ code?: ProblemCode | FilterErrorCode | (string & {});
192
+ correlationId?: string;
193
+ /** With a `FilterErrorCode`: the names of the mount's `filters` that were refused. */
194
+ filterNames?: readonly string[];
195
+ };
196
+ /** The filters in effect changed, by the user in the filter bar or by `setFilters`. */
197
+ filterChanged: {
198
+ dashboardId: string;
199
+ filters: Filters;
200
+ source: 'user' | 'host';
201
+ };
202
+ /**
203
+ * Host filters set after mount (`setFilters`, or a changed `filters` prop in React) were refused. The
204
+ * previous filters stay in effect and the mount goes on. Fires once per refused call.
205
+ */
206
+ filterRejected: {
207
+ dashboardId: string;
208
+ error: FilterError;
209
+ };
210
+ /** The dashboard's content size changed; the host may resize its container. */
211
+ sizeChanged: {
212
+ dashboardId: string;
213
+ width: number;
214
+ height: number;
215
+ };
216
+ }
217
+ export type EmbedEventName = keyof EmbedEventMap;
218
+ export type EmbedEventHandler<E extends EmbedEventName> = (payload: EmbedEventMap[E]) => void;
219
+ export interface EmbedOptions {
220
+ /** The Jitto dashboard id, as the tenant administrator gave it. */
221
+ dashboardId: string;
222
+ /**
223
+ * The tenant's own Jitto host, as an origin, exactly as Jitto or the install's administrator gave it:
224
+ * `https://`, a host, an optional port, nothing else (`https://example-tenant.jittocloud.com`). The SDK frames
225
+ * only `embedOrigin` + `/embed` (the API's `embed_url`), sends messages only to this origin, and accepts
226
+ * messages only from it and from its own frame. Nothing in the SDK derives a host from a tenant name. A value of
227
+ * another shape ends the mount at once with `failed`, reason `token`, before any token is requested. Read once,
228
+ * when the dashboard mounts: changing it on the options object later changes nothing. To show another host,
229
+ * unmount and mount again (`<JittoDashboard>` remounts when this prop changes).
230
+ */
231
+ embedOrigin: string;
232
+ /** How to get a dashboard token for the signed-in user (see `FetchToken`). */
233
+ fetchToken: FetchToken;
234
+ /**
235
+ * Not available yet in this version: ignored, with a console warning. What follows is the specified behaviour.
236
+ *
237
+ * Filters applied before the first render, by filter-bar name. If any is refused (`FilterErrorCode`),
238
+ * no chart renders: `failed` fires with reason `not_found`, that `code` and the refused `filterNames`.
239
+ */
240
+ filters?: Filters;
241
+ /** Theme tokens applied before the first render. */
242
+ theme?: ThemeTokens;
243
+ /** Deadline for one `fetchToken` call. Default 20 000. */
244
+ tokenDeadlineMs?: number;
245
+ /**
246
+ * How long the frame may stay silent after it is created. When it has not answered by then (for example
247
+ * because the page's website is not on the tenant's allowed origins, so the browser blocked the frame),
248
+ * `failed` fires with reason `network`. Default 30 000. Also the deadline of each `setFilters` call.
249
+ */
250
+ loadDeadlineMs?: number;
251
+ /**
252
+ * Waits before each retry of a retryable token failure (`RETRYABLE_STATUSES`, deadline, network), on
253
+ * mount and on refresh alike. Default [2000, 5000, 15000]. A 429's `retryAfterSeconds` lengthens a
254
+ * wait, never shortens it. When the last retry fails, `failed` fires with the reason of the last
255
+ * failure (`FAILURE_REASON_BY_CODE`, else `FAILURE_REASON_BY_STATUS`; a deadline is `token`, a network
256
+ * failure `network`). A refresh ends by the current token's expiry: a call's deadline is cut to the time
257
+ * left, and a retry whose wait would end at or after expiry is not made; the last failure then gives the
258
+ * reason. An expired-token error from the frame during a refresh waits for the refresh's outcome. The
259
+ * first token on mount has no token to outlast, so its retries run in full.
260
+ */
261
+ retryDelaysMs?: readonly number[];
262
+ }
263
+ /** Event callbacks, the same names in both wrappers. */
264
+ export interface EmbedCallbacks {
265
+ onLoaded?: EmbedEventHandler<'loaded'>;
266
+ onFailed?: EmbedEventHandler<'failed'>;
267
+ /** Not available yet in this version: never called. */
268
+ onFilterChanged?: EmbedEventHandler<'filterChanged'>;
269
+ /** Not available yet in this version: never called. */
270
+ onFilterRejected?: EmbedEventHandler<'filterRejected'>;
271
+ onSizeChanged?: EmbedEventHandler<'sizeChanged'>;
272
+ }
273
+ /** Why a `setFilters` promise rejected. */
274
+ export type SetFiltersOutcome = 'deadline' | 'ended' | 'replaced';
275
+ /** The rejection of `setFilters`. Not available yet in this version: `setFilters` always rejects with a plain `Error`. */
276
+ export interface SetFiltersError extends Error {
277
+ name: 'SetFiltersError';
278
+ outcome: SetFiltersOutcome;
279
+ }
280
+ /** What `mountJittoDashboard` returns. */
281
+ export interface JittoDashboardHandle {
282
+ readonly dashboardId: string;
283
+ /**
284
+ * Not available yet in this version: always rejects with a plain `Error`. What follows is the specified behaviour.
285
+ *
286
+ * Replace the host-set filters. Resolves once the dashboard applied them, then fires `filterChanged` with
287
+ * source `host`. Today this reloads the dashboard inside the frame with the current token (no `fetchToken`
288
+ * call, no second `loaded`; the user's own filter-bar changes and scroll position are lost). When the
289
+ * dashboard service can change filters in place, it does, with no change to this type.
290
+ *
291
+ * Always settles. Rejects with a `SetFiltersError`: `deadline` when the filters are not applied within
292
+ * `loadDeadlineMs` of the call (then `failed` fires with reason `network` and the mount ends; a token refresh
293
+ * does not extend the deadline), `ended` when the mount ends first or had already ended, `replaced` when a
294
+ * later `setFilters` call is made before this one is applied. Called before `loaded`, it sets the filters of
295
+ * the first render and resolves when `loaded` fires.
296
+ *
297
+ * All or nothing: if any name or value is refused, it rejects with a `FilterError` instead, fires
298
+ * `filterRejected`, applies nothing (the dashboard is not reloaded) and fires no `filterChanged`.
299
+ */
300
+ setFilters(filters: Filters): Promise<void>;
301
+ /** Replace the theme tokens in place: the frame is not reloaded. */
302
+ setTheme(theme: ThemeTokens): void;
303
+ /** Subscribe; returns an unsubscribe function. After it is called, the handler is not called again. */
304
+ on<E extends EmbedEventName>(event: E, handler: EmbedEventHandler<E>): () => void;
305
+ /** Remove the dashboard, cancel pending token requests and timers. Idempotent. */
306
+ unmount(): void;
307
+ }
308
+ export type MountJittoDashboard = (element: HTMLElement, options: EmbedOptions & EmbedCallbacks) => JittoDashboardHandle;
309
+ /**
310
+ * Props of `<JittoDashboard>`. The same options and callbacks as `mountJittoDashboard`, plus the
311
+ * container's class and style. A ref gives the same `JittoDashboardHandle`.
312
+ *
313
+ * Prop changes:
314
+ * - `dashboardId` or `embedOrigin` changed (by value): remount.
315
+ * - `filters` or `theme`: compared with the previous value by value (deep equality), never by reference.
316
+ * A different value is applied in place, as `setFilters` / `setTheme` would; an equal value does
317
+ * nothing, so inline objects in the host's render are safe and fire no `filterChanged`.
318
+ * - Callbacks, `fetchToken`, `tokenDeadlineMs`, `loadDeadlineMs`, `retryDelaysMs`: read when used. A new function or value
319
+ * takes effect from its next use, with no remount and no event.
320
+ */
321
+ export interface JittoDashboardProps extends EmbedOptions, EmbedCallbacks {
322
+ className?: string;
323
+ style?: Readonly<Record<string, string | number>>;
324
+ }
package/dist/types.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The public contract of `@jitto/analyze-embed` v1: everything a host app passes to the SDK and everything
3
+ * the SDK passes back. Both wrappers, `mountJittoDashboard()` (plain JavaScript) and
4
+ * `<JittoDashboard>` (React), are typed from this file and nothing else, so they cannot drift apart.
5
+ *
6
+ * This file is reviewed like an API: a change here is a change to the contract.
7
+ * The wire format `fetchToken` receives from `POST /api/v1/embed-tokens` is the embed API's `EmbedToken`.
8
+ * `fetchToken` calls it with the signed-in user's own access token; no shared account is involved.
9
+ *
10
+ * Version policy: package major N targets embed API `/api/vN/`. The SDK sends no version header.
11
+ *
12
+ * Names here are Jitto's only. How a dashboard is rendered inside the frame is internal to Jitto and
13
+ * never appears in a type, an option, an event or a console message of a production build.
14
+ */
15
+ /**
16
+ * Why the dashboard refused host filters:
17
+ * - `unknown_filter`: a name is not a filter-bar name of the dashboard.
18
+ * - `invalid_filter_value`: a value has the wrong kind for its filter (a `DateRange` for a value filter,
19
+ * a plain value or list for a date filter, a list for a single-choice filter, anything but a list of exactly
20
+ * two numbers or `null` for a numeric-range filter).
21
+ * A value that is the right kind but matches no row is not an error: the charts show zero rows.
22
+ */
23
+ export const FILTER_ERROR_CODES = ['unknown_filter', 'invalid_filter_value'];
24
+ /**
25
+ * How a problem code becomes a failure reason. Exhaustive: adding a code to `ProblemCode` without a
26
+ * reason here fails the typecheck. A code the SDK does not know maps by HTTP status
27
+ * (`FAILURE_REASON_BY_STATUS`). Both maps must equal openapi/embed.yaml's `x-failure-reason` and
28
+ * `x-failure-reason-by-status`; `scripts/check-contract-sync.mjs` compares them.
29
+ */
30
+ export const FAILURE_REASON_BY_CODE = {
31
+ invalid_request: 'network',
32
+ unauthenticated: 'token',
33
+ not_permitted: 'permission',
34
+ not_embeddable: 'permission',
35
+ origin_not_allowed: 'permission',
36
+ unknown_dashboard: 'not_found',
37
+ row_rules_incomplete: 'permission',
38
+ rate_limited: 'token',
39
+ internal_error: 'network',
40
+ upstream_unavailable: 'network',
41
+ embedding_not_configured: 'permission',
42
+ no_group: 'permission',
43
+ };
44
+ /**
45
+ * The fallback for a code the SDK does not know, by HTTP status. Any status not listed, and a rejection
46
+ * with no status (other than a deadline, which is `token`), maps to `default`.
47
+ */
48
+ export const FAILURE_REASON_BY_STATUS = {
49
+ 401: 'token',
50
+ 403: 'permission',
51
+ 404: 'not_found',
52
+ 429: 'token',
53
+ default: 'network',
54
+ };
55
+ /**
56
+ * Token request outcomes the SDK retries: no answer within `tokenDeadlineMs`, a network failure, and HTTP
57
+ * 429, 500 and 502. Every other failure (400, 401, 403, 404, 422, 503, an answer without a token, a token
58
+ * for another dashboard, an `embedUrl` that is not `embedOrigin` + `/embed`) ends the mount at once with
59
+ * `failed`. A refresh never outlasts the current token: see `retryDelaysMs`.
60
+ */
61
+ export const RETRYABLE_STATUSES = [429, 500, 502];
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@jitto/analyze-embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Embed Jitto dashboards in your web app, in React or plain JavaScript.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./react": {
16
+ "types": "./dist/react.d.ts",
17
+ "default": "./dist/react.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist/",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
27
+ "prepack": "npm run build"
28
+ },
29
+ "peerDependencies": {
30
+ "react": ">=18",
31
+ "@types/react": ">=18.2.6"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "react": { "optional": true },
35
+ "@types/react": { "optional": true }
36
+ },
37
+ "typesVersions": {
38
+ "*": {
39
+ "react": [
40
+ "dist/react.d.ts"
41
+ ]
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "19.3.0",
46
+ "typescript": "5.9.3"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }