import { ASTRALVIEW_SCHEMA_VERSION, createEmptyScreen, type ImageWidgetNode, type IframeWidgetNode, type Screen, type TextWidgetNode, type VideoWidgetNode, } from '../schema'; import { convertGoViewImageOptionToNodeProps, type GoViewImageOption } from '../widgets/image'; import { convertGoViewIframeOptionToNodeProps, type GoViewIframeOption } from '../widgets/iframe'; import { convertGoViewVideoOptionToNodeProps, type GoViewVideoOption } from '../widgets/video'; import { convertGoViewTextOptionToNodeProps, type GoViewTextOption } from '../widgets/text'; import { pickUrlLike } from '../widgets/urlLike'; export interface GoViewComponentLike { id?: string; // some exports wrap the actual component under a nested field component?: GoViewComponentLike; // component identity key?: string; // e.g. "TextCommon" (sometimes) componentKey?: string; chartConfig?: { key?: string; chartKey?: string; type?: string; option?: unknown; data?: unknown; }; // geometry attr?: { x: number; y: number; w: number; h: number; zIndex?: number }; // state status?: { lock?: boolean; hide?: boolean }; // widget-specific config option?: unknown; } export interface GoViewEditCanvasConfigLike { projectName?: string; width?: number; height?: number; background?: string; } export interface GoViewStorageLike { editCanvasConfig?: GoViewEditCanvasConfigLike; componentList?: GoViewComponentLike[]; } export interface GoViewProjectLike { width?: number; height?: number; canvas?: { width?: number; height?: number }; componentList?: GoViewComponentLike[]; // persisted store shape (some variants) editCanvasConfig?: GoViewEditCanvasConfigLike; } function unwrapComponent(c: GoViewComponentLike): GoViewComponentLike { // Prefer nested component shapes but keep outer fields as fallback. // Some exports wrap components multiple times like: // { id, attr, component: { component: { chartConfig, option } } } // We unwrap iteratively to avoid recursion pitfalls. let out: GoViewComponentLike = c; let depth = 0; while (out.component && depth < 6) { const inner = out.component; out = { // Prefer outer for geometry/id, but prefer inner for identity/option when present. ...out, ...inner, // keep unwrapping if there are more layers component: inner.component, }; depth++; } return out; } function keyOf(cIn: GoViewComponentLike): string { const c = unwrapComponent(cIn); return ( c.chartConfig?.key ?? c.chartConfig?.chartKey ?? c.chartConfig?.type ?? c.componentKey ?? c.key ?? '' ).toLowerCase(); } function isTextCommon(c: GoViewComponentLike): boolean { const k = keyOf(c); if (k === 'textcommon') return true; return k.includes('text'); } function isImage(c: GoViewComponentLike): boolean { const k = keyOf(c); // goView variants: "Image", "image", sometimes with suffixes. return k === 'image' || k.includes('image') || k.includes('picture'); } function isIframe(c: GoViewComponentLike): boolean { const k = keyOf(c); // goView variants: "Iframe", "IframeCommon", etc. if (k === 'iframe' || k.includes('iframe')) return true; // Other names seen in low-code editors for embedded web content. return ( k.includes('embed') || k.includes('webview') || k.includes('html') || k.includes('browser') || k.includes('webpage') || // keep the plain 'web' check last; it's broad and may overlap other widgets. k === 'web' || k.endsWith('_web') || k.startsWith('web_') ); } function isVideo(c: GoViewComponentLike): boolean { const k = keyOf(c); // goView variants: "Video", "VideoCommon", etc. if (k === 'video' || k.includes('video')) return true; // Misspellings / aliases seen in forks. if (k.includes('vedio')) return true; // Other names seen in the wild. return ( k.includes('mp4') || k.includes('media') || k.includes('player') || k.includes('stream') || k.includes('rtsp') || k.includes('rtmp') || k.includes('hls') || k.includes('m3u8') || k.includes('flv') || k.includes('webrtc') || k.includes('dash') || // common low-code names for live streams k.includes('live') || k.includes('camera') || k.includes('cctv') || k.includes('monitor') ); } function looksLikeImageOption(option: unknown): boolean { const url = pickUrlLike(option); if (!url) return false; // Common direct image URLs. if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url)) return true; // data urls if (/^data:image\//i.test(url)) return true; return false; } function looksLikeIframeOption(option: unknown): boolean { if (!option) return false; if (typeof option === 'string') { const trimmed = option.trim(); if (trimmed.startsWith('<') && trimmed.includes('>')) return true; } // Prefer explicit iframe-ish keys when option is an object. if (typeof option === 'object') { const o = option as Record; if ('iframeUrl' in o || 'iframeSrc' in o || 'embedUrl' in o) return true; // Some exports store raw HTML instead of a URL. if ('srcdoc' in o || 'srcDoc' in o) return true; if ('html' in o || 'htmlContent' in o || 'content' in o || 'template' in o) return true; } const url = pickUrlLike(option); if (!url) return false; // If it isn't an obvious media URL, it's often an iframe/embed. // (We deliberately keep this conservative; image/video are handled earlier.) return ( ( /^https?:\/\//i.test(url) || // protocol-relative URLs (//example.com) /^\/\//.test(url) || /^data:text\/html/i.test(url) ) && !/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url) && // Avoid misclassifying video streams as iframes. !/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url) && !/\bm3u8\b/i.test(url) && !/^(rtsp|rtmp):\/\//i.test(url) ); } function looksLikeVideoOption(option: unknown): boolean { if (!option) return false; // Prefer explicit video-ish keys when option is an object. if (typeof option === 'object') { const o = option as Record; if ( 'videoUrl' in o || 'videoSrc' in o || 'mp4' in o || 'm3u8' in o || 'flv' in o || 'hls' in o || 'rtsp' in o || // list-ish shapes 'sources' in o || 'sourceList' in o || 'urlList' in o || 'autoplay' in o || 'autoPlay' in o || 'isAutoPlay' in o || 'poster' in o || 'posterUrl' in o ) { return true; } } const url = pickUrlLike(option); if (!url) return false; // Common direct URL patterns. if (/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(url)) return true; if (/\bm3u8\b/i.test(url)) return true; // Streaming-ish protocols (seen in CCTV/camera widgets). if (/^(rtsp|rtmp):\/\//i.test(url)) return true; return false; } function pick(...values: Array): T | undefined { for (const v of values) { if (v !== undefined && v !== null) return v as T; } return undefined; } function optionOf(cIn: GoViewComponentLike): unknown { const c = unwrapComponent(cIn); const chartData = c.chartConfig?.data as Record | undefined; return ( c.option ?? c.chartConfig?.option ?? chartData?.option ?? chartData?.options ?? chartData?.config ?? chartData ?? {} ); } function toNumber(v: unknown, fallback: number): number { if (typeof v === 'number' && Number.isFinite(v)) return v; if (typeof v === 'string') { const n = Number(v); if (Number.isFinite(n)) return n; } return fallback; } function toMaybeNumber(v: unknown): number | undefined { if (typeof v === 'number' && Number.isFinite(v)) return v; if (typeof v === 'string') { const n = Number(v); if (Number.isFinite(n)) return n; } return undefined; } function pickNumberLike(input: unknown, keys: string[], maxDepth = 2): number | undefined { return pickNumberLikeInner(input, keys, maxDepth); } function pickNumberLikeInner(input: unknown, keys: string[], depth: number): number | undefined { if (!input) return undefined; if (typeof input !== 'object') return toMaybeNumber(input); const obj = input as Record; for (const key of keys) { const v = toMaybeNumber(obj[key]); if (v !== undefined) return v; } if (depth <= 0) return undefined; for (const key of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) { const nested = pickNumberLikeInner(obj[key], keys, depth - 1); if (nested !== undefined) return nested; } return undefined; } function pickSizeLike(option: unknown): { w?: number; h?: number } { // goView-ish variants use different keys and sometimes nest them under style/config. const w = pickNumberLike(option, ['width', 'w']); const h = pickNumberLike(option, ['height', 'h']); return { w, h }; } export function convertGoViewProjectToScreen(input: GoViewProjectLike | GoViewStorageLike): Screen { // goView exports vary a lot; attempt a few common nesting shapes. const root = input as unknown as Record; const data = (root.data as Record | undefined) ?? undefined; const state = (root.state as Record | undefined) ?? undefined; const project = (root.project as Record | undefined) ?? undefined; const editCanvasConfig = pick( (input as GoViewStorageLike).editCanvasConfig, data?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined, state?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined, project?.editCanvasConfig as GoViewEditCanvasConfigLike | undefined, ); const width = editCanvasConfig?.width ?? (input as GoViewProjectLike).canvas?.width ?? (input as GoViewProjectLike).width ?? (data?.width as number | undefined) ?? 1920; const height = editCanvasConfig?.height ?? (input as GoViewProjectLike).canvas?.height ?? (input as GoViewProjectLike).height ?? (data?.height as number | undefined) ?? 1080; const name = editCanvasConfig?.projectName ?? 'Imported Project'; const background = editCanvasConfig?.background; const screen = createEmptyScreen({ version: ASTRALVIEW_SCHEMA_VERSION, width, height, name, background: background ? { color: background } : undefined, nodes: [], }); const componentList = (input as GoViewStorageLike).componentList ?? (input as GoViewProjectLike).componentList ?? (data?.componentList as GoViewComponentLike[] | undefined) ?? (state?.componentList as GoViewComponentLike[] | undefined) ?? (project?.componentList as GoViewComponentLike[] | undefined) ?? []; const nodes: Array = []; for (const raw of componentList) { const c = unwrapComponent(raw); const option = optionOf(c); // We try to infer the widget kind early so we can pick better default sizes // when exports omit sizing information. // Important: run media/embed checks before text checks. // Some goView/fork widgets have misleading keys that contain "text" even though // the option payload is clearly video/iframe. const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined = isImage(c) || looksLikeImageOption(option) ? 'image' : // Important: run video checks before iframe checks; iframe URL detection is broader. isVideo(c) || looksLikeVideoOption(option) ? 'video' : isIframe(c) || looksLikeIframeOption(option) ? 'iframe' : isTextCommon(c) ? 'text' : undefined; const defaults = inferredType === 'text' ? { w: 320, h: 60 } : inferredType === 'image' ? { w: 320, h: 180 } : inferredType === 'iframe' ? { w: 480, h: 270 } : inferredType === 'video' ? { w: 480, h: 270 } : { w: 320, h: 60 }; const optSize = pickSizeLike(option); const attr = c.attr as unknown as Record | undefined; const rect = attr ? { x: toNumber(attr.x, 0), y: toNumber(attr.y, 0), // Prefer explicit attr sizing, but fall back to option sizing when missing. w: toNumber(attr.w, optSize.w ?? defaults.w), h: toNumber(attr.h, optSize.h ?? defaults.h), } : { x: 0, y: 0, w: optSize.w ?? defaults.w, h: optSize.h ?? defaults.h }; const zIndex = attr?.zIndex === undefined ? undefined : toNumber(attr.zIndex, 0); if (inferredType === 'text') { const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption); nodes.push({ id: c.id ?? `import_text_${Math.random().toString(16).slice(2)}`, type: 'text', rect, zIndex, locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, }); continue; } if (inferredType === 'image') { const props = convertGoViewImageOptionToNodeProps(option as GoViewImageOption); nodes.push({ id: c.id ?? `import_image_${Math.random().toString(16).slice(2)}`, type: 'image', rect, zIndex, locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, }); continue; } if (inferredType === 'iframe') { const props = convertGoViewIframeOptionToNodeProps(option as GoViewIframeOption); nodes.push({ id: c.id ?? `import_iframe_${Math.random().toString(16).slice(2)}`, type: 'iframe', rect, zIndex, locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, }); continue; } if (inferredType === 'video') { const props = convertGoViewVideoOptionToNodeProps(option as GoViewVideoOption); nodes.push({ id: c.id ?? `import_video_${Math.random().toString(16).slice(2)}`, type: 'video', rect, zIndex, locked: c.status?.lock ?? false, hidden: c.status?.hide ?? false, props, }); continue; } } return { ...screen, nodes, }; }