482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<T>(...values: Array<T | undefined | null>): 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<string, unknown> | 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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
const data = (root.data as Record<string, unknown> | undefined) ?? undefined;
|
|
const state = (root.state as Record<string, unknown> | undefined) ?? undefined;
|
|
const project = (root.project as Record<string, unknown> | undefined) ?? undefined;
|
|
|
|
const editCanvasConfig = pick<GoViewEditCanvasConfigLike>(
|
|
(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<TextWidgetNode | ImageWidgetNode | IframeWidgetNode | VideoWidgetNode> = [];
|
|
|
|
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<string, unknown> | 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,
|
|
};
|
|
}
|