@morlay/dsh-client-ui-primitives 0.0.2-alpha.8 → 0.0.2-alpha.9

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,77 @@
1
+ /**
2
+ * 一行的配置页:官方表单壳(保存 / 丢弃 / 只读 / 不可用)+ 行式 JSON 结构编辑器 + 字段槽。
3
+ *
4
+ * 每个可渲染的行注册项经 `renderFactorySlot('settings.schema-form.form', …)` 渲染这个 Factory,字段槽因此只需在
5
+ * Factory 的 `children` 里声明一次(slots 的 children 声明 per key 唯一,而本包给每个行都注册了一项)。
6
+ */
7
+
8
+ import { useSyncExternalStore, type ReactNode } from "react";
9
+ import { SettingsForm, type SettingsFormLabels } from "@deepseek-ai/dsh-client-ui-primitives";
10
+ import { Editor } from "./Editor.tsx";
11
+ import { parseFor } from "./fields.tsx";
12
+ import type { SchemaFieldOwnerProps, SchemaFormComponentProps } from "./slot-contract.ts";
13
+ import { Hint } from "./styles.ts";
14
+ import { SchemaFieldDefault } from "./value.tsx";
15
+
16
+ /**
17
+ * 渲染一行的配置页。
18
+ * @param props - 行 id、控制器面、本地化解析与框架注入的槽位渲染座位。
19
+ * @returns 表单壳里的行式编辑器,或「没有可自动生成的配置项」一行。
20
+ */
21
+ export function SchemaForm(props: SchemaFormComponentProps): ReactNode {
22
+ const { ns, face, t, resolveText, renderSlotChain } = props;
23
+ const store = face.hooks.schemaForm;
24
+ const state = useSyncExternalStore(
25
+ (listener) => store.subscribe(listener),
26
+ () => store.getSnapshot(),
27
+ );
28
+ const labels: SettingsFormLabels = {
29
+ unavailable: t("unavailable"),
30
+ readOnly: t("readOnly"),
31
+ saveFailed: t("saveFailed"),
32
+ save: t("save"),
33
+ saving: t("saving"),
34
+ };
35
+ if (!state.configured) return <Hint role="status">{t("noSchema")}</Hint>;
36
+ return (
37
+ <SettingsForm
38
+ labels={labels}
39
+ state={state}
40
+ onSave={() => {
41
+ face.save();
42
+ }}
43
+ onDiscard={() => {
44
+ face.discard();
45
+ }}
46
+ >
47
+ <Editor
48
+ ns={ns}
49
+ state={state}
50
+ face={face}
51
+ t={t}
52
+ resolveText={resolveText}
53
+ renderField={(owner: SchemaFieldOwnerProps) =>
54
+ renderSlotChain("settings.schema-form.field", owner, {
55
+ fallback: <SchemaFieldDefault owner={{ ...owner, onEditText: withParse(owner, t) }} />,
56
+ })
57
+ }
58
+ />
59
+ {/* 本地整段校验挡下的保存要说出来:否则点保存"没有反应"看起来像按钮坏了。 */}
60
+ {state.violation === undefined ? null : (
61
+ <Hint role="alert" data-violation="true">
62
+ {t("notSaved", { message: state.violation.message })}
63
+ </Hint>
64
+ )}
65
+ </SettingsForm>
66
+ );
67
+ }
68
+
69
+ /** 文本草稿的解析规则按字段类型给:行内编辑只上报原文,值在保存那一刻成形。 */
70
+ function withParse(
71
+ owner: SchemaFieldOwnerProps,
72
+ t: SchemaFormComponentProps["t"],
73
+ ): SchemaFieldOwnerProps["onEditText"] {
74
+ return (text, parse) => {
75
+ owner.onEditText(text, parse ?? parseFor(owner.node, t));
76
+ };
77
+ }
@@ -0,0 +1,457 @@
1
+ /**
2
+ * 一行的配置页控制器:把 describe 里的 volatile schema 投影成字段树,绑上该命名空间的共享配置表单与草稿模型。
3
+ *
4
+ * 一个命名空间一个实例(行 id 就是 settings 命名空间)。行被 describe 出来之后 `configured` 才是 true:
5
+ * schema 变了就重新投影字段树(模型不重建,草稿仍在),行消失时页面只显示一行说明。
6
+ */
7
+
8
+ import type { SettingsNamespaceView } from "@deepseek-ai/dsh-api-remotes/client";
9
+ import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store";
10
+ import type { SettingsFormShell } from "@deepseek-ai/dsh-client-ui-primitives";
11
+ import type { SchemaNode, SettingsDescribeFace } from "@deepseek-ai/dsh-client-ui-settings/client";
12
+ import {
13
+ SchemaDraftModel,
14
+ type DraftFieldState,
15
+ type DraftScope,
16
+ type Section,
17
+ type TextParse,
18
+ type ValidationFailure,
19
+ } from "./draft.ts";
20
+ import {
21
+ emptyMeta,
22
+ fieldsOf,
23
+ mergeKeyConflict,
24
+ projectNode,
25
+ readPath,
26
+ unwrapLazy,
27
+ variantOf,
28
+ walkFields,
29
+ type FieldNode,
30
+ type SuggestedKeys,
31
+ type WalkedField,
32
+ } from "./schema-node.ts";
33
+ import type { FieldText, SelectOption, SelectSpec } from "./hints.ts";
34
+ import type { SchemaFormTranslate } from "./slot-contract.ts";
35
+
36
+ /** `role('select', { source })` 里声明的候选源名字。 */
37
+ function declaredSource(node: FieldNode | undefined): string | undefined {
38
+ const extra = node?.meta.extra;
39
+ if (typeof extra !== "object" || extra === null) return undefined;
40
+ const name: unknown = Reflect.get(extra, "source");
41
+ return typeof name === "string" && name.length > 0 ? name : undefined;
42
+ }
43
+
44
+ /** 字段在状态表里的键(具体路径的 JSON)。 */
45
+ export function fieldKey(path: readonly string[]): string {
46
+ return JSON.stringify(path);
47
+ }
48
+
49
+ /**
50
+ * rehydrate 一段配置 schema,并判它能不能当页面的根。
51
+ *
52
+ * 能当根的形状有三种:对象段、**相交段**(共享判别字段 + 各分支的 `const` 标记,段值仍是对象)、以及带判别
53
+ * 标签的 `union`(成员都是对象)。字面量 `union`(整段就是一个标量)不行:那一行的写盘会变成整段替换。
54
+ * @param serialized - host 发来的 `schema.toJSON()`。
55
+ * @param rehydrate - host schema 的 rehydrate。
56
+ * @returns schema 与它的字段树,或 `undefined`(不能当页面根)。
57
+ */
58
+ export function projectRoot(
59
+ serialized: unknown,
60
+ rehydrate: (serialized: unknown) => SchemaNode,
61
+ ): { schema: SchemaNode; root: FieldNode } | undefined {
62
+ try {
63
+ const schema = rehydrate(serialized);
64
+ const root = projectNode(schema);
65
+ if (root.type === "object" || root.type === "intersect") return { schema, root };
66
+ if (root.type === "union" && root.tagKey !== undefined && root.choices === undefined) {
67
+ return { schema, root };
68
+ }
69
+ return undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 一行能否自动生成配置页:它的 schema 能 rehydrate 且根是 {@link projectRoot} 认的形状。
77
+ * @param view - host 发来的命名空间视图。
78
+ * @param rehydrate - host schema 的 rehydrate。
79
+ * @returns 是否可以渲染。
80
+ */
81
+ export function canRender(
82
+ view: SettingsNamespaceView | undefined,
83
+ rehydrate: (serialized: unknown) => SchemaNode,
84
+ ): boolean {
85
+ return view !== undefined && projectRoot(view.schema, rehydrate) !== undefined;
86
+ }
87
+
88
+ /** 段根不是可渲染对象时的空字段树。 */
89
+ const NO_FIELDS: FieldNode = {
90
+ key: "",
91
+ path: [],
92
+ label: undefined,
93
+ meta: emptyMeta(),
94
+ readOnly: null,
95
+ type: "object",
96
+ fields: [],
97
+ };
98
+
99
+ /** 提示面在控制器里的读法:候选键 + 字段文案 + 候选值。 */
100
+ export interface HintSources extends SuggestedKeys {
101
+ /**
102
+ * 读某个字段的文案。
103
+ * @param path - 该字段的具体路径。
104
+ * @returns 业务注册的文案;没有就是空对象。
105
+ */
106
+ textFor: (path: readonly string[]) => FieldText;
107
+ /**
108
+ * 读某个字段的候选值读数;不接提示面的装配可以不给(字段就还是文本编辑)。
109
+ * @param path - 该字段的具体路径。
110
+ * @returns 业务注册的候选读数;没有就是 `undefined`。
111
+ */
112
+ selectFor?: ((path: readonly string[]) => SelectSpec | undefined) | undefined;
113
+ /**
114
+ * 读一个**具名候选源**(schema 的 `role('select', { source })` 认领它)。
115
+ * @param name - 源的名字。
116
+ * @returns 该源的读数;没注册过就是 `undefined`。
117
+ */
118
+ sourceFor?: ((name: string) => SelectSpec | undefined) | undefined;
119
+ }
120
+
121
+ /** 控制器要的外部面(`apply` 里从 cordis ctx 组装;测试直接给替身)。 */
122
+ export interface SchemaFormDeps {
123
+ /** 本命名空间的共享配置表单(读 + 带栅栏的写)。 */
124
+ form: DraftScope;
125
+ /** describe mirror 的读面:本行的 schema、autoGenerate 与 secret 槽。 */
126
+ describe: SettingsDescribeFace;
127
+ /** rehydrate host 发来的 `schema.toJSON()`。 */
128
+ rehydrate: (serialized: unknown) => SchemaNode;
129
+ /** 整段校验;失败时连路径一起给(页面把消息画在那一行)。 */
130
+ validate: (schema: SchemaNode, value: unknown) => ValidationFailure | undefined;
131
+ /** 本包字典(项身份冲突这类本地校验的消息)。 */
132
+ t: SchemaFormTranslate;
133
+ /**
134
+ * dict 字段的候选键(业务注册,见 `hints.ts`):候选里有、值里没有的键会补成一行「未配置」,
135
+ * 用户填了才算写。
136
+ */
137
+ hints?: HintSources | undefined;
138
+ }
139
+
140
+ /**
141
+ * 这一层「能加进来」的一项:schema 里声明、值里还没有的字段,或业务注册的候选键。
142
+ *
143
+ * 页面上不占行——它们出现在容器的闭合行那个「添加属性 / 添加键」输入框的候选里,选中才加。
144
+ */
145
+ export interface AddableProperty {
146
+ /** 加进来的键名。 */
147
+ key: string;
148
+ /** 说明(schema 的 `description` / `comment`,可能是本地化字典):渲染层解析后当候选的副标题。 */
149
+ description: string | Readonly<Record<string, string>> | undefined;
150
+ }
151
+
152
+ /** 值里已有的键(数组给索引)。 */
153
+ function keysOf(value: unknown): string[] {
154
+ if (Array.isArray(value)) return value.map((_item, index) => String(index));
155
+ if (typeof value === "object" && value !== null) return Object.keys(value);
156
+ return [];
157
+ }
158
+
159
+ /**
160
+ * 一个容器当前能加什么。
161
+ *
162
+ * 对象层(含相交与 union 的选中变体)是**非必填**、值里又没有的字段——必填的已经占着行了;字典层是业务注册的
163
+ * 候选键(模式清单这类运行期数据)。数组层不在这里:它的「添加一项」是追加一个空项,没有键名可选。
164
+ * @param node - 容器节点。
165
+ * @param path - 该容器的具体路径。
166
+ * @param value - 该容器自己的值(草稿后的)。
167
+ * @param hints - 提示面(只用到候选键)。
168
+ * @returns 可添加项,顺序即 schema / 读数的顺序;列表为空时页面不给输入框。
169
+ */
170
+ export function addableAt(
171
+ node: FieldNode,
172
+ path: readonly string[],
173
+ value: unknown,
174
+ hints: SuggestedKeys | undefined,
175
+ ): readonly AddableProperty[] {
176
+ const current = variantOf(node, value);
177
+ if (current.type === "dict") {
178
+ const present = new Set(keysOf(value));
179
+ return (hints?.keysFor(path) ?? []).flatMap((key) =>
180
+ present.has(key) ? [] : [{ key, description: undefined }],
181
+ );
182
+ }
183
+ if (current.type !== "object" && current.type !== "intersect") return [];
184
+ return fieldsOf(current).flatMap((field) =>
185
+ field.meta.required !== true && readPath(value, [field.key]) === undefined
186
+ ? [{ key: field.key, description: field.meta.description ?? field.meta.comment }]
187
+ : [],
188
+ );
189
+ }
190
+
191
+ /** 页面级状态:表单壳 + 字段树 + 每字段草稿状态。 */
192
+ export interface SchemaFormState extends SettingsFormShell {
193
+ /** 这一行的 schema 是否可渲染(否则页面只显示一行说明)。 */
194
+ configured: boolean;
195
+ /** 字段树;未 configured 时是空对象树。 */
196
+ root: FieldNode;
197
+ /** 按草稿后的值展开的字段列表(渲染顺序):追加的数组项与新增的字典键立刻出现。 */
198
+ walked: readonly WalkedField[];
199
+ /** 每个具体路径的草稿状态,键见 {@link fieldKey}。 */
200
+ fields: ReadonlyMap<string, DraftFieldState>;
201
+ /** secret 槽是否已配置,键见 {@link fieldKey}。 */
202
+ secrets: ReadonlyMap<string, boolean>;
203
+ /** 每个字段的文案(提示面注册的),键见 {@link fieldKey};渲染层把它画成注释行。 */
204
+ texts: ReadonlyMap<string, FieldText>;
205
+ /**
206
+ * 每个字段的可选项,键见 {@link fieldKey}:字面量集合的 union 自带候选,其余来自提示面。
207
+ * 候选依赖的兄弟字段一改,这里就跟着变(投影时按草稿后的值求一次)。
208
+ */
209
+ options: ReadonlyMap<string, readonly SelectOption[]>;
210
+ /**
211
+ * 每个容器能加进来的项,键见 {@link fieldKey}(容器自己的路径)。没有条目就是加不了东西——对象层的输入框
212
+ * 因此只在还有声明字段没配时出现。
213
+ */
214
+ addable: ReadonlyMap<string, readonly AddableProperty[]>;
215
+ /** 保存被 schema 挡下时的消息与它的位置。 */
216
+ violation: ValidationFailure | undefined;
217
+ /** 每行的校验消息(键见 {@link fieldKey}):行内报错,与底部的整段提示同源。 */
218
+ invalidAt: ReadonlyMap<string, string>;
219
+ }
220
+
221
+ /** 控件的写动作:全部落在草稿上,`save` 是唯一的写盘点。 */
222
+ export interface SchemaFormActions {
223
+ /** 暂存一个合法值。 */
224
+ set: (path: readonly string[], value: unknown) => void;
225
+ /** 暂存一段文本(保存时解析)。 */
226
+ setText: (path: readonly string[], text: string, parse: TextParse) => void;
227
+ /** 暂存「恢复默认」。 */
228
+ clear: (path: readonly string[]) => void;
229
+ /** 追加一个数组项。 */
230
+ appendItem: (path: readonly string[]) => void;
231
+ /** 移除一个数组项。 */
232
+ removeItem: (path: readonly string[], index: number) => void;
233
+ /** 加一个字典键。 */
234
+ addKey: (path: readonly string[], key: string) => void;
235
+ /** 删一个字典键。 */
236
+ removeKey: (path: readonly string[], key: string) => void;
237
+ /** 写盘。 */
238
+ save: () => void;
239
+ /** 撤销一个字段的草稿(行内编辑取消)。 */
240
+ revert: (path: readonly string[]) => void;
241
+ /** 丢弃草稿。 */
242
+ discard: () => void;
243
+ }
244
+
245
+ /** 槽位渲染器绑定的动作与可观察状态。 */
246
+ export interface SchemaFormFace extends SchemaFormActions {
247
+ hooks: {
248
+ schemaForm: SnapshotStore<SchemaFormState>;
249
+ };
250
+ }
251
+
252
+ /**
253
+ * 一个字段的可选项。
254
+ *
255
+ * 字面量集合的 union(`z.union(["wal", "delete"])`)自己就带着候选;其余看提示面注册的读数——读的时候把依赖的
256
+ * 兄弟字段值喂进去,所以「选完服务商才列它有哪些模型」是投影的一部分,不是额外的一次订阅。
257
+ * @param node - 字段节点。
258
+ * @param path - 该字段的具体路径。
259
+ * @param value - 段值(草稿后的)。
260
+ * @param hints - 提示面。
261
+ * @returns 可选项;没有候选就是 `undefined`。
262
+ */
263
+ export function optionsFor(
264
+ node: FieldNode,
265
+ path: readonly string[],
266
+ value: unknown,
267
+ hints: HintSources | undefined,
268
+ ): readonly SelectOption[] | undefined {
269
+ const current = unwrapLazy(node);
270
+ if (current?.type === "union" && current.choices !== undefined) {
271
+ return current.choices.map((choice) => ({ value: choice }));
272
+ }
273
+ // schema 上写了 `role('select', { source })` 就用那个具名源(业务不必知道 ns/path);
274
+ // 否则退回按路径登记的那一份。
275
+ const source = declaredSource(current);
276
+ const spec = source === undefined ? hints?.selectFor?.(path) : hints?.sourceFor?.(source);
277
+ if (spec === undefined) return undefined;
278
+ const parent = path.slice(0, -1);
279
+ const options = spec.options((relative) => readPath(value, [...parent, ...relative]));
280
+ return options.length === 0 ? undefined : options;
281
+ }
282
+
283
+ /**
284
+ * 一行配置页的控制器。
285
+ */
286
+ export class SchemaFormController {
287
+ readonly #ns: string;
288
+ readonly #deps: SchemaFormDeps;
289
+ readonly #model: SchemaDraftModel;
290
+ readonly #store: SnapshotStore<SchemaFormState>;
291
+ readonly #disposers: (() => void)[] = [];
292
+ #view: SettingsNamespaceView | undefined;
293
+ #schema: SchemaNode | undefined;
294
+ #root: FieldNode | undefined;
295
+
296
+ /**
297
+ * @param ns - settings 命名空间(行 id)。
298
+ * @param deps - 该行的共享表单与 schema 面。
299
+ */
300
+ constructor(ns: string, deps: SchemaFormDeps) {
301
+ this.#ns = ns;
302
+ this.#deps = deps;
303
+ this.#model = new SchemaDraftModel({
304
+ scope: deps.form,
305
+ root: NO_FIELDS,
306
+ validate: (value) => this.#validate(deps, value),
307
+ });
308
+ this.#store = createSnapshotStore(this.#project());
309
+ this.#disposers.push(
310
+ this.#model.subscribe(() => {
311
+ this.#store.set(this.#project());
312
+ }),
313
+ );
314
+ this.#disposers.push(
315
+ deps.describe.subscribe(() => {
316
+ this.#sync();
317
+ }),
318
+ );
319
+ void deps.describe.ensure();
320
+ this.#sync();
321
+ }
322
+
323
+ /** 槽位渲染器要的动作与状态。 */
324
+ face(): SchemaFormFace {
325
+ return {
326
+ hooks: { schemaForm: this.#store },
327
+ set: (path, value) => {
328
+ this.#model.set(path, value);
329
+ },
330
+ setText: (path, text, parse) => {
331
+ this.#model.setText(path, text, parse);
332
+ },
333
+ clear: (path) => {
334
+ this.#model.clear(path);
335
+ },
336
+ appendItem: (path) => {
337
+ this.#model.appendItem(path);
338
+ },
339
+ removeItem: (path, index) => {
340
+ this.#model.removeItem(path, index);
341
+ },
342
+ addKey: (path, key) => {
343
+ this.#model.addKey(path, key);
344
+ },
345
+ removeKey: (path, key) => {
346
+ this.#model.removeKey(path, key);
347
+ },
348
+ save: () => {
349
+ void this.#model.save();
350
+ },
351
+ revert: (path) => {
352
+ this.#model.revert(path);
353
+ },
354
+ discard: () => {
355
+ this.#model.discard();
356
+ },
357
+ };
358
+ }
359
+
360
+ /** 本行的 settings 命名空间。 */
361
+ namespace(): string {
362
+ return this.#ns;
363
+ }
364
+
365
+ /** 外部提示(候选键这类)变了:重新投影一次,字段树与草稿都不动。 */
366
+ refresh(): void {
367
+ this.#store.set(this.#project());
368
+ }
369
+
370
+ /** 释放订阅。 */
371
+ dispose(): void {
372
+ for (const dispose of this.#disposers.splice(0)) dispose();
373
+ this.#model.dispose();
374
+ }
375
+
376
+ /** 重新读 schema(describe 变了):只换字段树,草稿与 store 不动。 */
377
+ #sync(): void {
378
+ const view = this.#deps.describe
379
+ .getSnapshot()
380
+ .view?.namespaces.find((row) => row.ns === this.#ns);
381
+ this.#view = view;
382
+ const projected =
383
+ view === undefined ? undefined : projectRoot(view.schema, this.#deps.rehydrate);
384
+ this.#schema = projected?.schema;
385
+ this.#root = projected?.root;
386
+ // 增删项要按新的字段树取默认值(行的 Config 可能刚改过)。
387
+ this.#model.setRoot(projected?.root ?? NO_FIELDS);
388
+ this.#store.set(this.#project());
389
+ }
390
+
391
+ /** 整段校验:schema 先说话,再看数组的项身份有没有冲突。 */
392
+ #validate(deps: SchemaFormDeps, value: Section): ValidationFailure | undefined {
393
+ if (this.#schema !== undefined) {
394
+ const failure = deps.validate(this.#schema, value);
395
+ if (failure !== undefined) return failure;
396
+ }
397
+ if (this.#root === undefined) return undefined;
398
+ const conflict = mergeKeyConflict(this.#root, value);
399
+ if (conflict === undefined) return undefined;
400
+ return {
401
+ message:
402
+ conflict.kind === "duplicate"
403
+ ? deps.t("duplicateItem", { key: conflict.mergeKey, value: conflict.value })
404
+ : deps.t("missingItem", { key: conflict.mergeKey }),
405
+ path: conflict.path,
406
+ };
407
+ }
408
+
409
+ /** 渲染层读的一份完整投影。 */
410
+ #project(): SchemaFormState {
411
+ const fields = new Map<string, DraftFieldState>();
412
+ const root = this.#root;
413
+ // 一次投影只算一份读数:字段树与每一行的值都从它来——容器的值因此也含别处的草稿(判别式 union 的选支就是它)。
414
+ const preview = this.#model.preview();
415
+ const walked = root === undefined ? [] : walkFields(root, preview);
416
+ for (const item of walked) {
417
+ fields.set(fieldKey(item.path), this.#model.field(item.path, preview));
418
+ }
419
+ const secrets = new Map<string, boolean>();
420
+ for (const secret of this.#view?.secrets ?? []) secrets.set(fieldKey(secret.path), secret.set);
421
+ const texts = new Map<string, FieldText>();
422
+ const options = new Map<string, readonly SelectOption[]>();
423
+ const addable = new Map<string, readonly AddableProperty[]>();
424
+ for (const item of walked) {
425
+ const text = this.#deps.hints?.textFor(item.path);
426
+ if (text !== undefined) texts.set(fieldKey(item.path), text);
427
+ const candidates = optionsFor(item.node, item.path, preview, this.#deps.hints);
428
+ if (candidates !== undefined) options.set(fieldKey(item.path), candidates);
429
+ const addableHere = addableAt(
430
+ item.node,
431
+ item.path,
432
+ readPath(preview, item.path),
433
+ this.#deps.hints,
434
+ );
435
+ if (addableHere.length > 0) addable.set(fieldKey(item.path), addableHere);
436
+ }
437
+ // 整段校验失败落在哪一行,就在哪一行说(底部只留整段级的那份)。
438
+ const violation = this.#model.violation();
439
+ const invalidAt = new Map<string, string>();
440
+ if (violation !== undefined && violation.path.length > 0) {
441
+ invalidAt.set(fieldKey(violation.path), violation.message);
442
+ }
443
+ return {
444
+ ...this.#model.shell(),
445
+ configured: root !== undefined,
446
+ root: root ?? NO_FIELDS,
447
+ walked,
448
+ fields,
449
+ secrets,
450
+ texts,
451
+ options,
452
+ addable,
453
+ violation,
454
+ invalidAt,
455
+ };
456
+ }
457
+ }