984 lines
29 KiB
TypeScript
984 lines
29 KiB
TypeScript
import {
|
|
ASTRALVIEW_SCHEMA_VERSION,
|
|
createEmptyScreen,
|
|
type Screen,
|
|
type WidgetNode,
|
|
} 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;
|
|
componentName?: string;
|
|
name?: string;
|
|
title?: string;
|
|
chartConfig?: {
|
|
key?: string;
|
|
chartKey?: string;
|
|
type?: string;
|
|
name?: string;
|
|
title?: string;
|
|
chartName?: 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 geometry/id as fallback.
|
|
// Some exports wrap components multiple times like:
|
|
// { id, attr, component: { component: { chartConfig, option } } }
|
|
// We unwrap iteratively to avoid recursion pitfalls.
|
|
//
|
|
// Important: do NOT let `undefined` values from inner layers overwrite outer values.
|
|
// Some goView exports include wrapper objects where `id/attr` only exist on the outer layer.
|
|
let out: GoViewComponentLike = c;
|
|
let depth = 0;
|
|
|
|
while (out.component && depth < 6) {
|
|
const outer = out;
|
|
const inner = out.component;
|
|
|
|
out = {
|
|
// Outer (wrapper) often contains the stable identity/geometry.
|
|
id: outer.id ?? inner.id,
|
|
attr: outer.attr ?? inner.attr,
|
|
status: outer.status ?? inner.status,
|
|
|
|
// Inner tends to carry the real widget identity/config.
|
|
key: inner.key ?? outer.key,
|
|
componentKey: inner.componentKey ?? outer.componentKey,
|
|
chartConfig: inner.chartConfig ?? outer.chartConfig,
|
|
option: inner.option ?? outer.option,
|
|
|
|
// 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.chartConfig?.name ??
|
|
c.chartConfig?.title ??
|
|
c.chartConfig?.chartName ??
|
|
c.componentKey ??
|
|
c.key ??
|
|
c.componentName ??
|
|
c.name ??
|
|
c.title ??
|
|
''
|
|
).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.
|
|
// Keep this fairly conservative; video/image are handled separately.
|
|
return (
|
|
k.includes('embed') ||
|
|
k === 'frame' ||
|
|
// Some forks label iframe-like widgets as "Frame" / "FrameCommon".
|
|
k.includes('frame') ||
|
|
k.includes('webview') ||
|
|
k.includes('html') ||
|
|
k.includes('browser') ||
|
|
k.includes('webpage') ||
|
|
k.includes('website') ||
|
|
// Dashboard/report embeds are almost always iframes.
|
|
k.includes('grafana') ||
|
|
k.includes('powerbi') ||
|
|
k.includes('metabase') ||
|
|
k.includes('superset') ||
|
|
k.includes('tableau') ||
|
|
// Chinese low-code widgets / dashboards sometimes label this more directly.
|
|
k.includes('网页') ||
|
|
k.includes('嵌入') ||
|
|
k.includes('内嵌') ||
|
|
k.includes('页面') ||
|
|
// Chinese low-code widgets sometimes call this H5.
|
|
k === 'h5' ||
|
|
k.includes('h5_') ||
|
|
k.includes('_h5') ||
|
|
k.includes('h5页面') ||
|
|
// keep the plain 'web' check last; it's broad and may overlap other widgets.
|
|
k === 'web' ||
|
|
k.endsWith('_web') ||
|
|
k.startsWith('web_') ||
|
|
// common embed platforms (usually rendered in an <iframe>)
|
|
k.includes('youtube') ||
|
|
k.includes('youtu') ||
|
|
k.includes('vimeo') ||
|
|
k.includes('bilibili') ||
|
|
k.includes('youku') ||
|
|
k.includes('tencent') ||
|
|
k.includes('iqiyi')
|
|
);
|
|
}
|
|
|
|
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 common widget key aliases (varies a lot across forks).
|
|
if (
|
|
k.includes('videoplayer') ||
|
|
k.includes('video_player') ||
|
|
k.includes('player_video') ||
|
|
k.includes('liveplayer') ||
|
|
k.includes('live_player') ||
|
|
k.includes('streamplayer') ||
|
|
k.includes('stream_player')
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Chinese low-code widget names.
|
|
if (
|
|
k.includes('\u89c6\u9891') ||
|
|
k.includes('\u76d1\u63a7') ||
|
|
k.includes('\u76f4\u64ad') ||
|
|
k.includes('\u6444\u50cf') ||
|
|
k.includes('\u6444\u50cf\u5934') ||
|
|
k.includes('\u56de\u653e')
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Other names seen in the wild.
|
|
return (
|
|
k.includes('mp4') ||
|
|
k.includes('media') ||
|
|
// "player" is broad; keep it but prefer more specific matches above.
|
|
k.includes('player') ||
|
|
// player implementation names frequently used in low-code widgets
|
|
k.includes('flvjs') ||
|
|
k.includes('hlsjs') ||
|
|
k.includes('dplayer') ||
|
|
k.includes('vlc') ||
|
|
// streaming/protocol keywords (often used directly as widget keys)
|
|
k.includes('stream') ||
|
|
k.includes('rtsp') ||
|
|
k.includes('rtmp') ||
|
|
k.includes('webrtc') ||
|
|
k.includes('srt') ||
|
|
k.includes('wss') ||
|
|
k.includes('ws') ||
|
|
k.includes('hls') ||
|
|
k.includes('m3u8') ||
|
|
k.includes('flv') ||
|
|
k.includes('dash') ||
|
|
// common low-code names for live streams
|
|
k.includes('live') ||
|
|
k.includes('camera') ||
|
|
k.includes('cctv') ||
|
|
k.includes('monitor') ||
|
|
k.includes('playback') ||
|
|
k.includes('streaming') ||
|
|
k.includes('videostream')
|
|
);
|
|
}
|
|
|
|
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 looksLikeTextOption(option: unknown): boolean {
|
|
if (!option || typeof option !== 'object') return false;
|
|
const o = option as Record<string, unknown>;
|
|
|
|
// goView TextCommon exports usually include a string dataset plus some typography-ish keys.
|
|
if (typeof o.dataset !== 'string') return false;
|
|
|
|
for (const key of [
|
|
'fontSize',
|
|
'fontColor',
|
|
'fontWeight',
|
|
'letterSpacing',
|
|
'paddingX',
|
|
'paddingY',
|
|
'textAlign',
|
|
'backgroundColor',
|
|
'borderWidth',
|
|
'borderColor',
|
|
'borderRadius',
|
|
'writingMode',
|
|
]) {
|
|
if (key in o) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function hasAnyKeyDeep(input: unknown, keys: string[], depth = 2): boolean {
|
|
if (!input || typeof input !== 'object') return false;
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
for (const k of keys) {
|
|
if (k in obj) return true;
|
|
}
|
|
|
|
if (depth <= 0) return false;
|
|
|
|
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
|
|
if (hasAnyKeyDeep(obj[nestKey], keys, depth - 1)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function containsVideoHtmlDeep(input: unknown, depth = 2): boolean {
|
|
if (!input || typeof input !== 'object') return false;
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
for (const key of [
|
|
'srcdoc',
|
|
'srcDoc',
|
|
'html',
|
|
'htmlContent',
|
|
'htmlString',
|
|
'iframeHtml',
|
|
'embedHtml',
|
|
'embedCode',
|
|
'iframeCode',
|
|
'iframeEmbed',
|
|
'embed',
|
|
'code',
|
|
'content',
|
|
'template',
|
|
]) {
|
|
const v = obj[key];
|
|
if (typeof v === 'string') {
|
|
const s = v.trim();
|
|
if (s.startsWith('<') && s.includes('>') && (/\bvideo\b/i.test(s) || /\bsource\b/i.test(s))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (depth <= 0) return false;
|
|
|
|
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
|
|
if (containsVideoHtmlDeep(obj[nestKey], depth - 1)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function looksLikeIframeOption(option: unknown): boolean {
|
|
if (!option) return false;
|
|
|
|
// Avoid false positives: some TextCommon widgets carry a link URL (or other URL-ish fields)
|
|
// but are still clearly text.
|
|
if (looksLikeTextOption(option)) return false;
|
|
|
|
if (typeof option === 'object') {
|
|
const typeHint = pickStringLikeDeep(
|
|
option,
|
|
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'embedType', 'frameType'],
|
|
2,
|
|
);
|
|
if (typeHint) {
|
|
const t = typeHint.toLowerCase();
|
|
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live)/i.test(t)) return false;
|
|
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return true;
|
|
}
|
|
}
|
|
|
|
if (typeof option === 'string') {
|
|
const trimmed = option.trim();
|
|
if (trimmed.startsWith('<') && trimmed.includes('>')) {
|
|
// If the HTML is a video tag, let the video detector handle it.
|
|
if (/\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed)) return false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Prefer explicit iframe-ish keys when option is an object (including nested shapes).
|
|
if (typeof option === 'object') {
|
|
// If the embed/html content is clearly a <video> snippet, don't classify as iframe.
|
|
// (Some exporters put a <video> tag under fields like `html` / `code`.)
|
|
if (containsVideoHtmlDeep(option, 2)) return false;
|
|
|
|
if (
|
|
hasAnyKeyDeep(
|
|
option,
|
|
[
|
|
'iframeUrl',
|
|
'iframeSrc',
|
|
'iframe',
|
|
'embedUrl',
|
|
'embedSrc',
|
|
'frameUrl',
|
|
'frameSrc',
|
|
'webUrl',
|
|
'websiteUrl',
|
|
'siteUrl',
|
|
'openUrl',
|
|
'openURL',
|
|
'linkUrl',
|
|
'linkURL',
|
|
'targetUrl',
|
|
'targetURL',
|
|
'webpageUrl',
|
|
'pageUrl',
|
|
'h5Url',
|
|
'link',
|
|
'href',
|
|
// list-ish (keep iframe-specific only; generic list keys are too common in Video widgets)
|
|
'iframeList',
|
|
],
|
|
3,
|
|
) ||
|
|
// Some exports store raw HTML instead of a URL.
|
|
hasAnyKeyDeep(
|
|
option,
|
|
[
|
|
'srcdoc',
|
|
'srcDoc',
|
|
'html',
|
|
'htmlContent',
|
|
'htmlString',
|
|
'iframe',
|
|
'iframeHtml',
|
|
'embedHtml',
|
|
'embedCode',
|
|
'iframeCode',
|
|
'iframeEmbed',
|
|
'embed',
|
|
'code',
|
|
'content',
|
|
'template',
|
|
],
|
|
3,
|
|
)
|
|
) {
|
|
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|webrtc|srt):\/\//i.test(url) &&
|
|
!/^wss?:\/\//i.test(url)
|
|
);
|
|
}
|
|
|
|
function looksLikeVideoOption(option: unknown): boolean {
|
|
if (!option) return false;
|
|
|
|
// Avoid false positives: some TextCommon widgets carry link-like fields.
|
|
if (looksLikeTextOption(option)) return false;
|
|
|
|
if (typeof option === 'object') {
|
|
const typeHint = pickStringLikeDeep(
|
|
option,
|
|
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'playerType', 'streamType'],
|
|
2,
|
|
);
|
|
if (typeHint) {
|
|
const t = typeHint.toLowerCase();
|
|
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return false;
|
|
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live|camera|cctv)/i.test(t)) return true;
|
|
}
|
|
}
|
|
|
|
if (typeof option === 'string') {
|
|
const trimmed = option.trim();
|
|
if (trimmed.startsWith('<') && trimmed.includes('>')) {
|
|
return /\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed);
|
|
}
|
|
}
|
|
|
|
// Prefer explicit video-ish keys when option is an object (including nested shapes).
|
|
if (typeof option === 'object') {
|
|
// Some exporters store a full <video> snippet under html-ish keys (html/code/template/etc).
|
|
// Treat that as a strong signal for a video widget.
|
|
if (containsVideoHtmlDeep(option, 2)) return true;
|
|
|
|
if (
|
|
hasAnyKeyDeep(
|
|
option,
|
|
[
|
|
'videoUrl',
|
|
'videoSrc',
|
|
'playUrl',
|
|
'srcUrl',
|
|
'sourceUrl',
|
|
'mediaUrl',
|
|
'mediaSrc',
|
|
'playbackUrl',
|
|
'playbackSrc',
|
|
'liveUrl',
|
|
'stream',
|
|
'streamUrl',
|
|
// very common forks / camera widgets
|
|
'rtspUrl',
|
|
'rtmpUrl',
|
|
'hlsUrl',
|
|
'flvUrl',
|
|
'm3u8Url',
|
|
'webrtcUrl',
|
|
'rtcUrl',
|
|
'wsUrl',
|
|
'srtUrl',
|
|
'dashUrl',
|
|
'cameraUrl',
|
|
'cctvUrl',
|
|
'monitorUrl',
|
|
// format keys
|
|
'mp4',
|
|
'm3u8',
|
|
'flv',
|
|
'hls',
|
|
'rtsp',
|
|
'rtmp',
|
|
// list-ish shapes
|
|
'sources',
|
|
'sourceList',
|
|
'urlList',
|
|
'srcList',
|
|
'playlist',
|
|
'playList',
|
|
'videos',
|
|
'videoList',
|
|
'items',
|
|
// playback flags
|
|
'autoplay',
|
|
'autoPlay',
|
|
'isAutoPlay',
|
|
'controls',
|
|
'showControls',
|
|
'showControl',
|
|
// common UI fields
|
|
'poster',
|
|
'posterUrl',
|
|
],
|
|
3,
|
|
)
|
|
) {
|
|
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|webrtc|srt):\/\//i.test(url)) return true;
|
|
// WebSocket-based stream relays.
|
|
if (/^wss?:\/\//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 looksLikeJsonString(input: string): boolean {
|
|
const s = input.trim();
|
|
if (!s) return false;
|
|
// Avoid parsing obvious URLs.
|
|
if (/^(https?:\/\/|\/\/|data:|rtsp:\/\/|rtmp:\/\/|webrtc:\/\/|srt:\/\/|wss?:\/\/)/i.test(s)) return false;
|
|
return (
|
|
(s.startsWith('{') && s.endsWith('}')) ||
|
|
(s.startsWith('[') && s.endsWith(']'))
|
|
);
|
|
}
|
|
|
|
function normalizeOption(option: unknown): unknown {
|
|
// Some exports accidentally store option blobs as JSON strings.
|
|
if (typeof option === 'string' && looksLikeJsonString(option)) {
|
|
try {
|
|
return JSON.parse(option) as unknown;
|
|
} catch {
|
|
return option;
|
|
}
|
|
}
|
|
|
|
return option;
|
|
}
|
|
|
|
function toMaybeString(v: unknown): string | undefined {
|
|
if (typeof v === 'string') return v;
|
|
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
|
return undefined;
|
|
}
|
|
|
|
function pickStringLikeDeep(input: unknown, keys: string[], depth = 2): string | undefined {
|
|
if (!input || typeof input !== 'object') return toMaybeString(input);
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
for (const key of keys) {
|
|
const v = toMaybeString(obj[key]);
|
|
if (v) return v;
|
|
}
|
|
|
|
if (depth <= 0) return undefined;
|
|
|
|
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
|
|
const nested = pickStringLikeDeep(obj[nestKey], keys, depth - 1);
|
|
if (nested) return nested;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
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 looksLikeHtml(input: string): boolean {
|
|
const s = input.trim();
|
|
return s.startsWith('<') && s.includes('>');
|
|
}
|
|
|
|
function extractHtmlAttribute(html: string, name: string): string | undefined {
|
|
// eslint-disable-next-line no-useless-escape
|
|
const re = new RegExp(`\\b${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i');
|
|
const match = re.exec(html);
|
|
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
}
|
|
|
|
function parseHtmlSizeAttr(value: string | undefined): number | undefined {
|
|
if (!value) return undefined;
|
|
const v = value.trim();
|
|
// only accept plain numbers or px values; ignore %/auto/etc.
|
|
const m = /^([0-9]+(?:\.[0-9]+)?)(px)?$/i.exec(v);
|
|
if (!m) return undefined;
|
|
const n = Number(m[1]);
|
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
}
|
|
|
|
function pickHtmlStringDeep(input: unknown, depth = 2): string | undefined {
|
|
if (typeof input === 'string') return looksLikeHtml(input) ? input : undefined;
|
|
if (!input || typeof input !== 'object') return undefined;
|
|
const obj = input as Record<string, unknown>;
|
|
|
|
for (const key of [
|
|
'html',
|
|
'htmlContent',
|
|
'htmlString',
|
|
'iframeHtml',
|
|
'embedHtml',
|
|
'embedCode',
|
|
'iframeCode',
|
|
'iframeEmbed',
|
|
'embed',
|
|
'code',
|
|
'content',
|
|
'template',
|
|
'srcdoc',
|
|
'srcDoc',
|
|
]) {
|
|
const v = obj[key];
|
|
if (typeof v === 'string' && looksLikeHtml(v)) return v;
|
|
}
|
|
|
|
if (depth <= 0) return undefined;
|
|
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
|
|
const v = pickHtmlStringDeep(obj[nestKey], depth - 1);
|
|
if (v) return v;
|
|
}
|
|
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']);
|
|
|
|
// Some forks store only an embed HTML snippet (iframe/video) with width/height.
|
|
// If attr sizing is missing, this helps us pick a better default rect.
|
|
const html = pickHtmlStringDeep(option, 3);
|
|
if (html) {
|
|
const wFromHtml = parseHtmlSizeAttr(extractHtmlAttribute(html, 'width'));
|
|
const hFromHtml = parseHtmlSizeAttr(extractHtmlAttribute(html, 'height'));
|
|
return { w: w ?? wFromHtml, h: h ?? hFromHtml };
|
|
}
|
|
|
|
return { w, h };
|
|
}
|
|
|
|
function pickAspectRatioLike(option: unknown): number | undefined {
|
|
const ratio = pickNumberLike(option, ['aspectRatio', 'aspect', 'ratio', 'aspect_ratio']);
|
|
if (ratio === undefined) return undefined;
|
|
return ratio > 0 ? ratio : undefined;
|
|
}
|
|
|
|
function applyAspectRatioToSize(
|
|
size: { w?: number; h?: number },
|
|
aspectRatio?: number,
|
|
): { w?: number; h?: number } {
|
|
if (!aspectRatio || aspectRatio <= 0) return size;
|
|
if (size.w && !size.h) return { ...size, h: Math.round(size.w / aspectRatio) };
|
|
if (size.h && !size.w) return { ...size, w: Math.round(size.h * aspectRatio) };
|
|
return size;
|
|
}
|
|
|
|
function normalizeRect(
|
|
rect: { x: number; y: number; w: number; h: number },
|
|
fallback: { w: number; h: number },
|
|
): { x: number; y: number; w: number; h: number } {
|
|
const w = rect.w > 0 ? rect.w : fallback.w;
|
|
const h = rect.h > 0 ? rect.h : fallback.h;
|
|
return { x: rect.x, y: rect.y, 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 = Math.max(
|
|
1,
|
|
toNumber(
|
|
editCanvasConfig?.width ??
|
|
(input as GoViewProjectLike).canvas?.width ??
|
|
(input as GoViewProjectLike).width ??
|
|
(data?.width as number | string | undefined) ??
|
|
1920,
|
|
1920,
|
|
),
|
|
);
|
|
|
|
const height = Math.max(
|
|
1,
|
|
toNumber(
|
|
editCanvasConfig?.height ??
|
|
(input as GoViewProjectLike).canvas?.height ??
|
|
(input as GoViewProjectLike).height ??
|
|
(data?.height as number | string | undefined) ??
|
|
1080,
|
|
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 componentListRaw =
|
|
(input as GoViewStorageLike).componentList ??
|
|
(input as GoViewProjectLike).componentList ??
|
|
(data?.componentList as GoViewComponentLike[] | undefined) ??
|
|
(state?.componentList as GoViewComponentLike[] | undefined) ??
|
|
(project?.componentList as GoViewComponentLike[] | undefined) ??
|
|
[];
|
|
const componentList = Array.isArray(componentListRaw) ? componentListRaw : [];
|
|
|
|
const nodes: WidgetNode[] = [];
|
|
|
|
for (const raw of componentList) {
|
|
if (!raw || typeof raw !== 'object') continue;
|
|
const c = unwrapComponent(raw);
|
|
const option = normalizeOption(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.
|
|
// Prefer evidence from the option payload over the widget key.
|
|
// Some goView/fork widgets mislabel embedded platforms (YouTube/Vimeo/etc) as "Video" even
|
|
// though they are best represented as an iframe.
|
|
const optionSaysImage = looksLikeImageOption(option);
|
|
const optionSaysVideo = looksLikeVideoOption(option);
|
|
const optionSaysIframe = looksLikeIframeOption(option);
|
|
|
|
// Some goView/fork widgets mislabel embedded platforms (YouTube/Vimeo/etc) as "Video"
|
|
// even though they are best represented as an iframe (a web page, not a media stream).
|
|
const urlLike = pickUrlLike(option);
|
|
const urlLooksLikeEmbedPage =
|
|
!!urlLike &&
|
|
/(youtube\.com|youtu\.be|vimeo\.com|twitch\.tv|tiktok\.com|douyin\.com|kuaishou\.com|ixigua\.com|huya\.com|douyu\.com|bilibili\.com|live\.bilibili\.com|player\.bilibili\.com|youku\.com|iqiyi\.com|mgtv\.com|tencent|qq\.com|music\.163\.com|spotify\.com|soundcloud\.com|figma\.com|google\.com\/maps|amap\.com|gaode\.com|map\.baidu\.com)/i.test(
|
|
urlLike,
|
|
) &&
|
|
// Keep actual media URLs as video.
|
|
!/\.(mp4|m3u8|flv|webm|mov|m4v|ogv)(\?|#|$)/i.test(urlLike) &&
|
|
!/^data:video\//i.test(urlLike) &&
|
|
!/^(rtsp|rtmp|webrtc|srt):\/\//i.test(urlLike) &&
|
|
!/^wss?:\/\//i.test(urlLike);
|
|
|
|
// Prefer evidence from the option payload over the widget key.
|
|
// Many forks mislabel widget keys (e.g. "Text*" / "Image*"), but the option payload
|
|
// clearly indicates iframe/video/image.
|
|
const inferredType: 'text' | 'image' | 'iframe' | 'video' | undefined =
|
|
optionSaysIframe
|
|
? 'iframe'
|
|
: optionSaysVideo
|
|
? urlLooksLikeEmbedPage
|
|
? 'iframe'
|
|
: 'video'
|
|
: optionSaysImage
|
|
? 'image'
|
|
: isIframe(c)
|
|
? 'iframe'
|
|
: isVideo(c)
|
|
? 'video'
|
|
: isImage(c)
|
|
? 'image'
|
|
: 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 = applyAspectRatioToSize(pickSizeLike(option), pickAspectRatioLike(option));
|
|
|
|
const attr = c.attr as unknown as Record<string, unknown> | undefined;
|
|
const rawRect = 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 rect = normalizeRect(rawRect, defaults);
|
|
|
|
const zIndex = attr?.zIndex === undefined ? undefined : toNumber(attr.zIndex, 0);
|
|
const baseId = toMaybeString(c.id);
|
|
|
|
if (inferredType === 'text') {
|
|
const props = convertGoViewTextOptionToNodeProps(option as GoViewTextOption);
|
|
nodes.push({
|
|
id: baseId ?? `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: baseId ?? `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: baseId ?? `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: baseId ?? `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,
|
|
};
|
|
}
|