feat(grid): add column formatters

This commit is contained in:
t8y2
2026-05-15 23:27:09 +08:00
parent a14a6977fa
commit 1b934e72e0
8 changed files with 579 additions and 11 deletions
+289 -11
View File
@@ -81,6 +81,13 @@ import {
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import {
applyColumnFormatter,
buildColumnFormatterKey,
normalizeColumnFormatter,
type ColumnFormatterConfig,
type DateTimeFormatterUnit,
} from "@/lib/columnFormatter";
import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
@@ -268,6 +275,12 @@ const localColumnFilters = ref<Record<number, Set<string>>>({});
const localFilterOpenColumn = ref<number | null>(null);
const localFilterSearch = ref("");
const localFilterDraft = ref<LocalColumnFilterDraft | null>(null);
const formatterOpenColumn = ref<number | null>(null);
const formatterKind = ref<ColumnFormatterConfig["kind"]>("datetime");
const formatterDateUnit = ref<DateTimeFormatterUnit>("auto");
const formatterJsonPath = ref("$.user.name");
const formatterMaskPrefix = ref(4);
const formatterMaskSuffix = ref(4);
function localFilterKey(value: CellValue): string {
if (value === null) return "__dbx_null__";
@@ -276,8 +289,8 @@ function localFilterKey(value: CellValue): string {
return `str:${String(value)}`;
}
function localFilterLabel(value: CellValue): string {
return value === null ? "NULL" : formatCell(value);
function localFilterLabel(value: CellValue, columnIndex: number): string {
return value === null ? "NULL" : formatCell(value, columnIndex);
}
function localFilterActive(colIdx: number): boolean {
@@ -318,7 +331,7 @@ function buildLocalFilterOptions(columnIndex: number) {
if (current) {
current.count += 1;
} else {
byKey.set(key, { key, label: localFilterLabel(value), count: 1, value });
byKey.set(key, { key, label: localFilterLabel(value, columnIndex), count: 1, value });
}
};
@@ -371,6 +384,96 @@ function closeLocalFilter() {
localFilterSearch.value = "";
}
function formatterKeyForColumn(column: string): string | null {
if (!props.connectionId || !props.tableMeta) return null;
return buildColumnFormatterKey({
connectionId: props.connectionId,
database: props.database,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
column,
});
}
function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
const column = props.result.columns[columnIndex];
if (!column) return undefined;
const key = formatterKeyForColumn(column);
return key ? settingsStore.editorSettings.columnFormatters[key] : undefined;
}
function columnHasFormatter(columnIndex: number): boolean {
return !!columnFormatter(columnIndex);
}
function currentFormatterDraft(): ColumnFormatterConfig {
if (formatterKind.value === "json-path") {
return { kind: "json-path", path: formatterJsonPath.value.trim() || "$" };
}
if (formatterKind.value === "mask") {
return {
kind: "mask",
prefix: Math.max(0, Math.floor(Number(formatterMaskPrefix.value) || 0)),
suffix: Math.max(0, Math.floor(Number(formatterMaskSuffix.value) || 0)),
};
}
return { kind: "datetime", unit: formatterDateUnit.value };
}
function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
const draft = formatter ?? { kind: "datetime", unit: "auto" as const };
formatterKind.value = draft.kind;
if (draft.kind === "datetime") {
formatterDateUnit.value = draft.unit;
} else if (draft.kind === "json-path") {
formatterJsonPath.value = draft.path;
} else if (draft.kind === "mask") {
formatterMaskPrefix.value = draft.prefix;
formatterMaskSuffix.value = draft.suffix;
}
}
function openColumnFormatter(columnIndex: number) {
loadFormatterDraft(columnFormatter(columnIndex));
formatterOpenColumn.value = columnIndex;
}
function closeColumnFormatter() {
formatterOpenColumn.value = null;
}
function saveColumnFormatter(columnIndex: number) {
const column = props.result.columns[columnIndex];
const key = column ? formatterKeyForColumn(column) : null;
if (!key) return;
settingsStore.updateColumnFormatter(key, currentFormatterDraft());
closeColumnFormatter();
}
function clearColumnFormatter(columnIndex: number) {
const column = props.result.columns[columnIndex];
const key = column ? formatterKeyForColumn(column) : null;
if (!key) return;
settingsStore.updateColumnFormatter(key, undefined);
closeColumnFormatter();
}
function formatterDraftIsSavable(): boolean {
return !!normalizeColumnFormatter(currentFormatterDraft());
}
function formatterPreviewRows(columnIndex: number) {
const formatter = currentFormatterDraft();
return displayItems.value.slice(0, 5).map((item, index) => {
const value = item.data[columnIndex] ?? null;
return {
index: index + 1,
raw: displayCellValue(value),
formatted: applyColumnFormatter(value, formatter),
};
});
}
function toggleLocalFilterValue(key: string) {
const draft = localFilterDraft.value;
if (!draft) return;
@@ -1077,7 +1180,7 @@ const sortedRows = computed(() => {
const rows = props.result.rows;
indices = indices.filter((sourceIndex) => {
const data = rows[sourceIndex];
return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q));
return data.some((cell, columnIndex) => cell !== null && formatCell(cell, columnIndex).toLowerCase().includes(q));
});
}
return indices;
@@ -1133,7 +1236,7 @@ const searchMatches = computed<SearchMatch[]>(() => {
for (let r = 0; r < items.length; r++) {
const data = items[r].data;
for (let c = 0; c < data.length; c++) {
if (data[c] !== null && String(data[c]).toLowerCase().includes(q)) {
if (data[c] !== null && formatCell(data[c], c).toLowerCase().includes(q)) {
matches.push({ displayRow: r, col: c });
}
}
@@ -1320,6 +1423,7 @@ const activeCellDetail = computed(() => {
if (!item || !column) return null;
const value = item.data[cell.col] ?? null;
const rawValue = displayCellValue(value);
const displayValue = formatCell(value, cell.col);
const valueText = value === null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
const trimmed = valueText.trim();
const maybeJson = typeof value === "string" && (trimmed.startsWith("{") || trimmed.startsWith("["));
@@ -1340,6 +1444,7 @@ const activeCellDetail = computed(() => {
comment: columnCommentMap.value.get(column) || "",
value,
rawValue,
displayValue,
length: value === null ? 0 : String(value).length,
formattedJson,
isEditable: canEditRowItem(item),
@@ -1557,10 +1662,9 @@ async function applyWhereFilter() {
const CELL_DISPLAY_MAX_LENGTH = 256;
function formatCell(value: CellValue): string {
if (value === null) return "NULL";
if (typeof value === "boolean") return value ? "true" : "false";
const s = typeof value === "object" ? JSON.stringify(value) : String(value);
function formatCell(value: CellValue, columnIndex?: number): string {
const formatter = columnIndex === undefined ? undefined : columnFormatter(columnIndex);
const s = applyColumnFormatter(value, formatter);
return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s;
}
@@ -1742,7 +1846,7 @@ const transposeData = computed(() => {
column: col,
type: columnTypeMap.value.get(col) || "",
value: item.data[columnIndex],
display: formatCell(item.data[columnIndex]),
display: formatCell(item.data[columnIndex], columnIndex),
isNull: item.data[columnIndex] === null,
};
});
@@ -2406,6 +2510,173 @@ defineExpose({
/>
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
<Popover
:open="formatterOpenColumn === actualColumnIndex(colIdx)"
@update:open="
(value: boolean) =>
value ? openColumnFormatter(actualColumnIndex(colIdx)) : closeColumnFormatter()
"
>
<PopoverTrigger as-child>
<button
type="button"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
:class="
columnHasFormatter(actualColumnIndex(colIdx))
? 'text-primary opacity-100'
: 'opacity-80'
"
:disabled="!formatterKeyForColumn(col)"
:title="t('grid.columnFormatter')"
@click.stop
>
<Code2 class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
class="w-[380px] max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
@click.stop
@keydown.stop
>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-sm font-semibold">
{{ t("grid.columnFormatterFor", { column: col }) }}
</div>
<div class="mt-0.5 text-[11px] text-muted-foreground">
{{ t("grid.columnFormatterHint") }}
</div>
</div>
<div class="space-y-3 p-3">
<div class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterType") }}
</div>
<Select
:model-value="formatterKind"
@update:model-value="(value: any) => (formatterKind = value)"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="datetime">{{ t("grid.formatterDatetime") }}</SelectItem>
<SelectItem value="json-path">{{ t("grid.formatterJsonPath") }}</SelectItem>
<SelectItem value="mask">{{ t("grid.formatterMask") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="formatterKind === 'datetime'" class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterTimestampUnit") }}
</div>
<Select
:model-value="formatterDateUnit"
@update:model-value="(value: any) => (formatterDateUnit = value)"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("grid.formatterUnitAuto") }}</SelectItem>
<SelectItem value="seconds">{{ t("grid.formatterUnitSeconds") }}</SelectItem>
<SelectItem value="milliseconds">{{
t("grid.formatterUnitMilliseconds")
}}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-else-if="formatterKind === 'json-path'" class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterJsonPathInput") }}
</div>
<input
v-model="formatterJsonPath"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-8 w-full rounded border bg-background px-2 font-mono text-xs outline-none focus:border-primary"
placeholder="$.user.name"
/>
</div>
<div v-else class="grid grid-cols-2 gap-2">
<label class="space-y-1.5">
<span class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterMaskPrefix") }}
</span>
<input
v-model.number="formatterMaskPrefix"
type="number"
min="0"
class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus:border-primary"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterMaskSuffix") }}
</span>
<input
v-model.number="formatterMaskSuffix"
type="number"
min="0"
class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus:border-primary"
/>
</label>
</div>
<div class="space-y-1.5">
<div class="text-xs font-medium text-muted-foreground">
{{ t("grid.formatterPreview") }}
</div>
<div class="max-h-40 overflow-auto rounded border bg-muted/20">
<div
v-for="row in formatterPreviewRows(actualColumnIndex(colIdx))"
:key="row.index"
class="grid grid-cols-[2rem_minmax(0,1fr)_minmax(0,1fr)] gap-2 border-b px-2 py-1.5 text-[11px] last:border-b-0"
>
<span class="text-muted-foreground">{{ row.index }}</span>
<span class="truncate font-mono text-muted-foreground">{{ row.raw }}</span>
<span class="truncate font-mono">{{ row.formatted }}</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="!columnHasFormatter(actualColumnIndex(colIdx))"
@click="clearColumnFormatter(actualColumnIndex(colIdx))"
>
{{ t("grid.clearFormatter") }}
</Button>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="h-7 px-2 text-xs"
@click="closeColumnFormatter"
>
{{ t("dangerDialog.cancel") }}
</Button>
<Button
size="sm"
class="h-7 px-2 text-xs"
:disabled="!formatterDraftIsSavable()"
@click="saveColumnFormatter(actualColumnIndex(colIdx))"
>
{{ t("grid.saveFormatter") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Popover
:open="localFilterOpenColumn === actualColumnIndex(colIdx)"
@update:open="
@@ -2683,7 +2954,7 @@ defineExpose({
/>
</template>
<template v-else>
{{ formatCell(item.data[actualColIdx]) }}
{{ formatCell(item.data[actualColIdx], actualColIdx) }}
<button
class="absolute right-0.5 top-0.5 hidden h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground group-hover/cell:flex"
:title="t('grid.cellDetails')"
@@ -2925,6 +3196,13 @@ defineExpose({
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
:class="{ 'cursor-text': activeCellDetail.isEditable }"
@dblclick="startDetailEdit"
>{{ activeCellDetail.displayValue }}</pre
>
</div>
<div v-if="activeCellDetail.displayValue !== activeCellDetail.rawValue" class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.rawValue") }}</div>
<pre
class="max-h-40 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
>{{ activeCellDetail.rawValue }}</pre
>
</div>
+18
View File
@@ -263,6 +263,23 @@ export default {
filter: "Filter",
localFilter: "Local column filter",
localFilterFor: "Local Filter For '{column}'",
columnFormatter: "Column formatter",
columnFormatterFor: "Formatter for '{column}'",
columnFormatterHint: "Formats display only; raw values stay unchanged.",
formatterType: "Type",
formatterDatetime: "Unix timestamp",
formatterJsonPath: "JSON path",
formatterMask: "Mask text",
formatterTimestampUnit: "Timestamp unit",
formatterUnitAuto: "Auto",
formatterUnitSeconds: "Seconds",
formatterUnitMilliseconds: "Milliseconds",
formatterJsonPathInput: "JSON path",
formatterMaskPrefix: "Visible prefix",
formatterMaskSuffix: "Visible suffix",
formatterPreview: "Preview",
saveFormatter: "Save",
clearFormatter: "Clear",
searchValues: "Search values...",
value: "Value",
count: "Count",
@@ -321,6 +338,7 @@ export default {
nullValue: "NULL",
noComment: "No comment",
formattedJson: "Formatted JSON",
rawValue: "Raw Value",
copyValue: "Copy Value",
editValue: "Edit Value",
setNull: "Set NULL",
+18
View File
@@ -260,6 +260,23 @@ export default {
filter: "筛选",
localFilter: "本地列筛选",
localFilterFor: "本地筛选“{column}”",
columnFormatter: "列格式化",
columnFormatterFor: "格式化“{column}”",
columnFormatterHint: "仅改变显示效果,原始值保持不变。",
formatterType: "类型",
formatterDatetime: "Unix 时间戳",
formatterJsonPath: "JSON 路径",
formatterMask: "文本脱敏",
formatterTimestampUnit: "时间戳单位",
formatterUnitAuto: "自动",
formatterUnitSeconds: "秒",
formatterUnitMilliseconds: "毫秒",
formatterJsonPathInput: "JSON 路径",
formatterMaskPrefix: "前缀保留",
formatterMaskSuffix: "后缀保留",
formatterPreview: "预览",
saveFormatter: "保存",
clearFormatter: "清除",
searchValues: "搜索值...",
value: "值",
count: "数量",
@@ -318,6 +335,7 @@ export default {
nullValue: "NULL",
noComment: "暂无注释",
formattedJson: "格式化 JSON",
rawValue: "原始值",
copyValue: "复制值",
editValue: "编辑值",
setNull: "设为 NULL",
+138
View File
@@ -0,0 +1,138 @@
import { displayCellValue, type CellValue } from "@/lib/cellValue";
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
export type ColumnFormatterConfig =
| { kind: "datetime"; unit: DateTimeFormatterUnit }
| { kind: "json-path"; path: string }
| { kind: "mask"; prefix: number; suffix: number };
export interface ColumnFormatterKeyParts {
connectionId: string;
database?: string;
schema?: string;
tableName: string;
column: string;
}
export function buildColumnFormatterKey(parts: ColumnFormatterKeyParts): string {
return [parts.connectionId, parts.database ?? "", parts.schema ?? "", parts.tableName, parts.column].join("::");
}
export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig | undefined {
if (!value || typeof value !== "object") return undefined;
const config = value as Record<string, unknown>;
if (config.kind === "datetime") {
return config.unit === "seconds" || config.unit === "milliseconds" || config.unit === "auto"
? { kind: "datetime", unit: config.unit }
: undefined;
}
if (config.kind === "json-path") {
return typeof config.path === "string" && isSupportedJsonPath(config.path)
? { kind: "json-path", path: config.path }
: undefined;
}
if (config.kind === "mask") {
if (!Number.isInteger(config.prefix) || !Number.isInteger(config.suffix)) return undefined;
if ((config.prefix as number) < 0 || (config.suffix as number) < 0) return undefined;
return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number };
}
return undefined;
}
export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string {
if (!formatter) return displayCellValue(value);
if (value === null) return displayCellValue(value);
try {
if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit);
if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path);
if (formatter.kind === "mask") return formatMask(value, formatter);
return displayCellValue(value);
} catch {
return displayCellValue(value);
}
}
function formatDateTime(value: Exclude<CellValue, null>, unit: DateTimeFormatterUnit): string {
const numeric = typeof value === "number" ? value : Number(String(value).trim());
if (!Number.isFinite(numeric)) return displayCellValue(value);
const timestamp =
unit === "seconds" || (unit === "auto" && Math.abs(numeric) < 100_000_000_000) ? numeric * 1000 : numeric;
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? displayCellValue(value) : date.toLocaleString();
}
function formatJsonPath(value: Exclude<CellValue, null>, path: string): string {
if (typeof value !== "string") return displayCellValue(value);
const parsed = JSON.parse(value);
const tokens = parseJsonPath(path);
let current: unknown = parsed;
for (const token of tokens) {
if (current == null) return "";
if (typeof token === "number") {
if (!Array.isArray(current)) return "";
current = current[token];
} else {
if (typeof current !== "object" || Array.isArray(current)) return "";
current = (current as Record<string, unknown>)[token];
}
}
if (current === undefined) return "";
if (current === null) return "NULL";
if (typeof current === "object") return JSON.stringify(current);
return String(current);
}
function formatMask(
value: Exclude<CellValue, null>,
formatter: Extract<ColumnFormatterConfig, { kind: "mask" }>,
): string {
const text = displayCellValue(value);
const visibleCount = formatter.prefix + formatter.suffix;
if (text.length <= visibleCount) return "*".repeat(text.length);
return `${text.slice(0, formatter.prefix)}${"*".repeat(text.length - visibleCount)}${text.slice(
text.length - formatter.suffix,
)}`;
}
function isSupportedJsonPath(path: string): boolean {
if (!path.startsWith("$")) return false;
try {
parseJsonPath(path);
return true;
} catch {
return false;
}
}
function parseJsonPath(path: string): Array<string | number> {
const tokens: Array<string | number> = [];
let index = 1;
while (index < path.length) {
if (path[index] === ".") {
const match = path.slice(index + 1).match(/^[A-Za-z_$][\w$]*/);
if (!match) throw new Error("Invalid JSON path");
tokens.push(match[0]);
index += match[0].length + 1;
continue;
}
if (path[index] === "[") {
const match = path.slice(index).match(/^\[(\d+)\]/);
if (!match) throw new Error("Invalid JSON path");
tokens.push(Number(match[1]));
index += match[0].length;
continue;
}
throw new Error("Invalid JSON path");
}
return tokens;
}
+26
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "@/lib/api";
import { normalizeColumnFormatter, type ColumnFormatterConfig } from "@/lib/columnFormatter";
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/shortcutRegistry";
import type { SidebarActivation } from "@/lib/treeNodeClick";
@@ -56,6 +57,7 @@ export interface EditorSettings {
redisScanPageSize: number;
shortcuts: ShortcutSettings;
sidebarActivation: SidebarActivation;
columnFormatters: Record<string, ColumnFormatterConfig>;
}
export const EDITOR_THEMES: { value: EditorTheme; label: string; dark: boolean }[] = [
@@ -91,11 +93,22 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
redisScanPageSize: 1000,
shortcuts: normalizeShortcutSettings(),
sidebarActivation: "single",
columnFormatters: {},
};
export const STORAGE_KEY = "dbx-editor-settings";
const OLD_FONT_SIZE_KEY = "dbx-query-editor-font-size";
function normalizeColumnFormatters(value: unknown): Record<string, ColumnFormatterConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const formatters: Record<string, ColumnFormatterConfig> = {};
for (const [key, formatter] of Object.entries(value as Record<string, unknown>)) {
const normalized = normalizeColumnFormatter(formatter);
if (normalized) formatters[key] = normalized;
}
return formatters;
}
export function normalizeEditorSettings(settings: Partial<EditorSettings>): EditorSettings {
return {
fontFamily: settings.fontFamily ?? DEFAULT_EDITOR_SETTINGS.fontFamily,
@@ -111,6 +124,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>): Edit
settings.sidebarActivation === "single" || settings.sidebarActivation === "double"
? settings.sidebarActivation
: DEFAULT_EDITOR_SETTINGS.sidebarActivation,
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
};
}
@@ -190,6 +204,17 @@ export const useSettingsStore = defineStore("settings", () => {
saveEditorSettings(editorSettings.value);
}
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
const columnFormatters = { ...editorSettings.value.columnFormatters };
const normalized = normalizeColumnFormatter(formatter);
if (normalized) {
columnFormatters[key] = normalized;
} else {
delete columnFormatters[key];
}
updateEditorSettings({ columnFormatters });
}
return {
aiConfig,
isAiConfigLoaded,
@@ -198,5 +223,6 @@ export const useSettingsStore = defineStore("settings", () => {
isConfigured,
editorSettings,
updateEditorSettings,
updateColumnFormatter,
};
});