import type { DetachedPlacementMemory, FloatingPlacementMemory, LayoutConfig, PaneBinding, PaneInstanceConfig, PanePlacementMemory, } from "../../types/config"; import type { BrokerContractRef, TickerListingRef } from "../../types/instrument"; import { cloneLayout, clonePaneSettings, createPaneInstanceId, getPlacedPaneInstanceIds, normalizePaneLayout, normalizePaneId, removeUnreachablePaneInstances, } from "../../types/config"; import { migrateChartPaneSettings, type LegacyChartMigrationContext, } from "./chart-settings"; export function isLayoutConfig(value: unknown): value is LayoutConfig { return !!value && typeof value === "object" && Array.isArray((value as LayoutConfig).instances) && Array.isArray((value as LayoutConfig).floating) && "dockRoot" in (value as Record); } function sanitizePaneBinding(value: unknown, fallback: PaneBinding = { kind: "none" }): PaneBinding { if (!value || typeof value !== "object") return fallback; if ((value as PaneBinding).kind === "fixed" && typeof (value as Extract).symbol === "string") { const binding = value as Extract; const instrument = sanitizeBoundInstrument(binding.instrument); const listing = sanitizeBoundListing(binding.listing); return { kind: "fixed", symbol: binding.symbol, ...(instrument !== undefined ? { instrument } : {}), ...(listing ? { listing } : {}), }; } if ((value as PaneBinding).kind === "follow" && typeof (value as Extract).sourceInstanceId === "string") { return { kind: "follow", sourceInstanceId: (value as Extract).sourceInstanceId }; } if ((value as PaneBinding).kind === "none") return { kind: "none" }; return fallback; } function sanitizeBoundInstrument(value: unknown): BrokerContractRef | null | undefined { if (value === null) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; if (typeof raw.brokerId !== "string" || !raw.brokerId.trim() || typeof raw.symbol !== "string" || !raw.symbol.trim()) return undefined; const result: BrokerContractRef = { brokerId: raw.brokerId, symbol: raw.symbol }; for (const key of ["brokerInstanceId", "localSymbol", "secType", "exchange", "primaryExchange", "currency", "lastTradeDateOrContractMonth", "multiplier", "tradingClass"] as const) { if (typeof raw[key] === "string") result[key] = raw[key]; } if (typeof raw.conId === "number" && Number.isSafeInteger(raw.conId) && raw.conId > 0) result.conId = raw.conId; if (typeof raw.strike === "number" && Number.isFinite(raw.strike)) result.strike = raw.strike; if (raw.right === "C" || raw.right === "P") result.right = raw.right; return result; } function sanitizeBoundListing(value: unknown): TickerListingRef | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; if (typeof raw.name !== "string" || typeof raw.exchange !== "string" || typeof raw.type !== "string") return undefined; return { name: raw.name, exchange: raw.exchange, type: raw.type, ...(typeof raw.currency === "string" ? { currency: raw.currency } : {}), }; } function sanitizeFloatingPlacementMemory(value: unknown): FloatingPlacementMemory | undefined { if (!value || typeof value !== "object") return undefined; const x = typeof (value as FloatingPlacementMemory).x === "number" ? Math.max(0, Math.round((value as FloatingPlacementMemory).x)) : null; const y = typeof (value as FloatingPlacementMemory).y === "number" ? Math.max(0, Math.round((value as FloatingPlacementMemory).y)) : null; const width = typeof (value as FloatingPlacementMemory).width === "number" ? Math.max(1, Math.round((value as FloatingPlacementMemory).width)) : null; const height = typeof (value as FloatingPlacementMemory).height === "number" ? Math.max(1, Math.round((value as FloatingPlacementMemory).height)) : null; if (x === null || y === null || width === null || height === null) return undefined; return { x, y, width, height }; } function sanitizeDetachedPlacementMemory(value: unknown): DetachedPlacementMemory | undefined { if (!value || typeof value !== "object") return undefined; const x = typeof (value as DetachedPlacementMemory).x === "number" ? Math.max(0, Math.round((value as DetachedPlacementMemory).x)) : null; const y = typeof (value as DetachedPlacementMemory).y === "number" ? Math.max(0, Math.round((value as DetachedPlacementMemory).y)) : null; const width = typeof (value as DetachedPlacementMemory).width === "number" ? Math.max(1, Math.round((value as DetachedPlacementMemory).width)) : null; const height = typeof (value as DetachedPlacementMemory).height === "number" ? Math.max(1, Math.round((value as DetachedPlacementMemory).height)) : null; if (x === null || y === null || width === null || height === null) return undefined; return { x, y, width, height }; } function sanitizePlacementMemory(value: unknown): PanePlacementMemory | undefined { if (!value || typeof value !== "object") return undefined; const docked = (() => { const raw = (value as PanePlacementMemory).docked; if (!raw || typeof raw !== "object") return undefined; const rawPath = (raw as { path?: unknown }).path; const path = Array.isArray(rawPath) ? rawPath.filter((segment): segment is 0 | 1 => segment === 0 || segment === 1) : undefined; const anchorInstanceId = typeof (raw as { anchorInstanceId?: unknown }).anchorInstanceId === "string" ? (raw as { anchorInstanceId: string }).anchorInstanceId : undefined; const position = ["left", "right", "above", "below"].includes(String((raw as { position?: unknown }).position)) ? (raw as { position: "left" | "right" | "above" | "below" }).position : undefined; if (!path && !anchorInstanceId && !position) return undefined; return { path, anchorInstanceId, position, }; })(); const floating = sanitizeFloatingPlacementMemory((value as PanePlacementMemory).floating); const detached = sanitizeDetachedPlacementMemory((value as PanePlacementMemory).detached); if (!docked && !floating && !detached) return undefined; return { docked, floating, detached }; } function sanitizePaneSettings(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const sanitizeValue = (entry: unknown): unknown => { if (entry == null) return entry; if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { return entry; } if (Array.isArray(entry)) { return entry .map((child) => sanitizeValue(child)) .filter((child) => child !== undefined); } if (typeof entry === "object") { return Object.fromEntries( Object.entries(entry as Record) .map(([key, child]) => [key, sanitizeValue(child)]) .filter(([, child]) => child !== undefined), ); } return undefined; }; const settings = Object.fromEntries( Object.entries(value as Record) .map(([key, entry]) => [key, sanitizeValue(entry)]) .filter(([, entry]) => entry !== undefined), ); return Object.keys(settings).length > 0 ? clonePaneSettings(settings) : undefined; } function sanitizePaneInstances( value: unknown, fallback: LayoutConfig, chartMigration: LegacyChartMigrationContext, ): PaneInstanceConfig[] { if (!Array.isArray(value)) return cloneLayout(fallback).instances; const seen = new Set(); const instances = value .filter((entry): entry is PaneInstanceConfig => !!entry && typeof entry === "object" && typeof (entry as PaneInstanceConfig).instanceId === "string" && typeof (entry as PaneInstanceConfig).paneId === "string", ) .map((entry) => { const originalPaneId = entry.paneId; const paneId = normalizePaneId(entry.paneId); const instanceId = seen.has(entry.instanceId) ? createPaneInstanceId(paneId) : entry.instanceId; seen.add(instanceId); const binding = sanitizePaneBinding(entry.binding); const settings = sanitizePaneSettings(entry.settings); return { instanceId, paneId, title: typeof entry.title === "string" ? entry.title : undefined, binding, params: entry.params && typeof entry.params === "object" ? Object.fromEntries( Object.entries(entry.params).filter((param): param is [string, string] => typeof param[1] === "string"), ) : undefined, settings: migrateChartPaneSettings(originalPaneId, binding, settings, chartMigration), placementMemory: sanitizePlacementMemory(entry.placementMemory), locked: entry.locked === true ? true : undefined, }; }); return instances; } function getDefaultFollowSourceInstanceId(layout: LayoutConfig): string | null { const placedPaneIds = new Set(getPlacedPaneInstanceIds(layout)); return layout.instances.find((instance) => ( instance.paneId === "portfolio-list" && placedPaneIds.has(instance.instanceId) ))?.instanceId ?? null; } function sanitizeFloatingEntries(value: unknown, validInstanceIds: Set): LayoutConfig["floating"] { if (!Array.isArray(value)) return []; return value .filter((entry): entry is LayoutConfig["floating"][number] => !!entry && typeof entry === "object" && typeof (entry as LayoutConfig["floating"][number]).instanceId === "string" && typeof (entry as LayoutConfig["floating"][number]).x === "number" && typeof (entry as LayoutConfig["floating"][number]).y === "number" && typeof (entry as LayoutConfig["floating"][number]).width === "number" && typeof (entry as LayoutConfig["floating"][number]).height === "number", ) .filter((entry) => validInstanceIds.has(entry.instanceId)) .map((entry) => ({ ...entry, x: Math.max(0, Math.round(entry.x)), y: Math.max(0, Math.round(entry.y)), width: Math.max(1, Math.round(entry.width)), height: Math.max(1, Math.round(entry.height)), zIndex: typeof entry.zIndex === "number" ? Math.round(entry.zIndex) : entry.zIndex, })); } function sanitizeDetachedEntries(value: unknown, validInstanceIds: Set): LayoutConfig["detached"] { if (!Array.isArray(value)) return []; return value .filter((entry): entry is LayoutConfig["detached"][number] => !!entry && typeof entry === "object" && typeof (entry as LayoutConfig["detached"][number]).instanceId === "string" && typeof (entry as LayoutConfig["detached"][number]).x === "number" && typeof (entry as LayoutConfig["detached"][number]).y === "number" && typeof (entry as LayoutConfig["detached"][number]).width === "number" && typeof (entry as LayoutConfig["detached"][number]).height === "number", ) .filter((entry) => validInstanceIds.has(entry.instanceId)) .map((entry) => ({ instanceId: entry.instanceId, x: Math.max(0, Math.round(entry.x)), y: Math.max(0, Math.round(entry.y)), width: Math.max(1, Math.round(entry.width)), height: Math.max(1, Math.round(entry.height)), })); } export function sanitizeLayout( value: unknown, fallback: LayoutConfig, chartMigration: LegacyChartMigrationContext = {}, ): LayoutConfig { if (!isLayoutConfig(value)) { return cloneLayout(fallback); } if (!Array.isArray((value as LayoutConfig & { instances?: unknown }).instances)) { const layout = cloneLayout(fallback); return normalizePaneLayout(layout, { defaultFollowSourceInstanceId: getDefaultFollowSourceInstanceId(layout), resolveOrphanSymbol: () => null, }); } const instances = sanitizePaneInstances( (value as LayoutConfig & { instances?: unknown }).instances, fallback, chartMigration, ); const validInstanceIds = new Set(instances.map((entry) => entry.instanceId)); const dockRoot = (value as { dockRoot?: LayoutConfig["dockRoot"] }).dockRoot ?? null; const floating = sanitizeFloatingEntries((value as { floating?: unknown }).floating, validInstanceIds); const detached = sanitizeDetachedEntries((value as { detached?: unknown }).detached, validInstanceIds); const layout: LayoutConfig = { dockRoot, instances, floating, detached, }; const normalized = normalizePaneLayout(layout, { defaultFollowSourceInstanceId: getDefaultFollowSourceInstanceId(layout), resolveOrphanSymbol: () => null, }); return removeUnreachablePaneInstances(normalized); }