Files
vsix-hpl-toolbox/src/tools/playworksConverter.ts

668 lines
28 KiB
TypeScript

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
interface ConvertOptions {
sourcePath: string;
outputDir: string;
baseName?: string;
networks: string[];
}
interface NetworkResult {
network: string;
file: string;
ok: boolean;
error?: string;
}
export function openPlayworksConverter(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.playworksConverter', 'Playworks Converter');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
switch (msg.type) {
case 'pickSource': {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select Playworks HTML',
});
if (!picked?.[0]) return;
const srcPath = picked[0].fsPath;
const dir = path.dirname(srcPath);
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
break;
}
case 'pickOutput': {
const picked = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Select Output Folder',
});
if (!picked?.[0]) return;
panel.webview.postMessage({ type: 'outputPicked', path: picked[0].fsPath });
break;
}
case 'convert': {
const opts: ConvertOptions = msg.opts;
if (!fs.existsSync(opts.sourcePath)) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Source file not found.' });
return;
}
if (!fs.existsSync(opts.outputDir)) {
try { fs.mkdirSync(opts.outputDir, { recursive: true }); } catch {
panel.webview.postMessage({ type: 'convertDone', results: [], error: 'Could not create output folder.' });
return;
}
}
let html: string;
try {
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
html = fs.readFileSync(opts.sourcePath, 'utf8');
} catch (e: any) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
return;
}
const results: NetworkResult[] = [];
try {
panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' });
results.push({ network: 'un', file: copyUnityInput(opts), ok: true });
} catch (e: any) {
results.push({ network: 'un', file: '', ok: false, error: e.message });
}
for (let i = 0; i < opts.networks.length; i++) {
const network = opts.networks[i];
panel.webview.postMessage({ type: 'status', message: `Converting ${i + 1}/${opts.networks.length} ${network}` });
try {
const filePath = await convertNetwork(html, opts, network);
results.push({ network, file: filePath, ok: true });
} catch (e: any) {
results.push({ network, file: '', ok: false, error: e.message });
}
}
panel.webview.postMessage({ type: 'convertDone', results });
break;
}
case 'openOutput': {
vscode.env.openExternal(vscode.Uri.file(msg.dir));
break;
}
}
});
}
const ZIPPED_NETWORKS = new Set(['gg', 'ap', 'vu', 'mtg']);
const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'ap', 'mo', 'vu', 'mtg', 'tt']);
const MRAID_NETWORKS = new Set(['al', 'is']);
const EXITAPI_NETWORKS = new Set(['gg', 'ap']);
const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
al: 'Applovin',
is: 'Ironsource',
fb: 'Facebook',
gg: 'GoogleAds',
ap: 'Appreciate',
mo: 'Moloco',
vu: 'Vungle',
mtg: 'Mintegral',
tt: 'TikTok',
};
function sourceBaseName(sourcePath: string): string {
return path.basename(sourcePath, path.extname(sourcePath))
.replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/, '');
}
function outputBaseName(opts: ConvertOptions): string {
const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath);
const withoutExtension = rawBaseName.replace(/\.html?$/i, '');
const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim();
return safeBaseName || sourceBaseName(opts.sourcePath);
}
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
if (!SUPPORTED_NETWORKS.has(network)) {
throw new Error(`Unsupported Playworks target network: ${network}`);
}
const transformed = transformHtml(html, network);
const needsZip = ZIPPED_NETWORKS.has(network);
const baseName = outputBaseName(opts);
const htmlFileName = `${baseName}_${network}.html`;
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
fs.mkdirSync(outputDir, { recursive: true });
if (needsZip) {
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
fs.writeFileSync(tmpPath, transformed, 'utf8');
try {
await createZip(tmpPath, 'index.html', zipPath);
} finally {
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
}
return zipPath;
}
const outPath = path.join(outputDir, htmlFileName);
fs.writeFileSync(outPath, transformed, 'utf8');
return outPath;
}
function copyUnityInput(opts: ConvertOptions): string {
const baseName = outputBaseName(opts);
const outputDir = path.join(opts.outputDir, 'Unity');
const outPath = path.join(outputDir, `${baseName}_un.html`);
fs.mkdirSync(outputDir, { recursive: true });
if (path.resolve(opts.sourcePath) === path.resolve(outPath)) {
return outPath;
}
fs.copyFileSync(opts.sourcePath, outPath);
return outPath;
}
function transformHtml(html: string, network: string): string {
let result = sanitizePlayworksHtml(html);
// Inject into </head>: lifecycle stubs only (must register luna:build listener
// BEFORE the Playworks bundle so our handler fires first), then any network SDK script.
// Console restore stays at end-of-body — it must run AFTER the bundle overrides console.
result = removeNetworkSdkScripts(result);
let headInject = buildLifecycleScript();
if (MRAID_NETWORKS.has(network)) {
headInject += '<script src="mraid.js"></script>' + buildMraidComplianceScript();
} else if (EXITAPI_NETWORKS.has(network)) {
headInject += '<script src="exitapi.js"></script>';
}
result = injectBeforeHeadClose(result, headInject);
// Body-level flags
if (network === 'vu') {
result = injectAfterBodyOpen(result, '<script>window.__VUNGLE__=true;</script>');
} else if (network === 'mtg') {
result = addBodyOnload(result, 'gameReady()');
} else if (network === 'tt') {
result = injectAfterBodyOpen(result, '<script>window.__TIKTOK__=true;</script>');
}
// Replace the CTA script
result = replaceCTAScript(result, network);
return finalizeNetworkHtml(result, network);
}
function sanitizePlayworksHtml(html: string): string {
let result = cleanupPlayworksHtml(html);
return removeNetworkSdkScripts(result);
}
function cleanupPlayworksHtml(html: string): string {
let result = html;
result = trimAfterFirstHtmlClose(result);
result = result.replace(/<script\b[^>]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, '');
result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,');
result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, '');
result = result.replace(/\s+type\s*=\s*["']module["']/gi, '');
result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn(');
return result;
}
function trimAfterFirstHtmlClose(html: string): string {
const idx = html.search(/<\/html\s*>/i);
if (idx === -1) return html;
const close = html.match(/<\/html\s*>/i);
return close ? html.slice(0, idx + close[0].length) : html;
}
function finalizeNetworkHtml(html: string, network: string): string {
let result = cleanupPlayworksHtml(html);
result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network));
result = dedupeScriptSrc(result, 'exitapi.js', EXITAPI_NETWORKS.has(network));
return result;
}
function removeNetworkSdkScripts(html: string): string {
let result = html;
for (const src of ['mraid.js', 'exitapi.js']) {
const escaped = src.replace('.', '\\.');
result = result.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), '');
}
return result;
}
function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string {
const escaped = src.replace('.', '\\.');
let seen = false;
let result = html.replace(new RegExp(`<script\\b[^>]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => {
if (shouldExist && !seen) {
seen = true;
return `<script src="${src}"></script>`;
}
return '';
});
if (shouldExist && !seen) {
result = injectBeforeHeadClose(result, `<script src="${src}"></script>`);
}
return result;
}
function injectBeforeHeadClose(html: string, injection: string): string {
if (/<\/head>/i.test(html)) {
return html.replace(/<\/head>/i, `${injection}\n</head>`);
}
return `${injection}\n${html}`;
}
function injectAfterBodyOpen(html: string, injection: string): string {
if (/<body\b[^>]*>/i.test(html)) {
return html.replace(/<body\b[^>]*>/i, match => `${match}\n${injection}`);
}
return `${injection}\n${html}`;
}
function addBodyOnload(html: string, handler: string): string {
if (!/<body\b[^>]*>/i.test(html)) {
return `<body onload="${handler}">\n${html}`;
}
return html.replace(/<body\b([^>]*)>/i, (match, attrs: string) => {
const onloadMatch = attrs.match(/\bonload\s*=\s*(["'])(.*?)\1/i);
if (!onloadMatch) return `<body${attrs} onload="${handler}">`;
if (onloadMatch[2].includes(handler)) return match;
const updated = `${onloadMatch[2]};${handler}`;
return match.replace(onloadMatch[0], `onload=${onloadMatch[1]}${updated}${onloadMatch[1]}`);
});
}
function replaceCTAScript(html: string, network: string): string {
const marker = 'Luna.Unity.Playable.InstallFullGame=function';
const markerIdx = html.lastIndexOf(marker);
if (markerIdx === -1) return html;
const scriptOpenIdx = html.lastIndexOf('<script>', markerIdx);
if (scriptOpenIdx === -1) return html;
const scriptCloseIdx = html.indexOf('</script>', markerIdx);
if (scriptCloseIdx === -1) return html;
const before = html.slice(0, scriptOpenIdx);
const after = html.slice(scriptCloseIdx + '</script>'.length);
// Console restore runs after the bundle (which overrides console), before CTA
return before + buildConsoleRestoreScript() + buildCTAScript(network) + after;
}
// Restores native console methods overridden by the Playworks bundle,
// using a temporary iframe to get a fresh un-patched console reference.
function buildConsoleRestoreScript(): string {
return [
'<script>(function(){',
'try{',
'var f=document.createElement("iframe");',
'f.style.display="none";',
'document.documentElement.appendChild(f);',
'var nc=f.contentWindow.console;',
'document.documentElement.removeChild(f);',
'var methods=["log","warn","error","info","debug","dir","table","group","groupEnd","groupCollapsed","time","timeEnd","assert","count","countReset","trace"];',
'methods.forEach(function(m){try{if(typeof nc[m]==="function")window.console[m]=nc[m].bind(nc);}catch(e){}});',
'}catch(e){}',
'})();</script>',
].join('');
}
// Exposes gameReady/Start/End/Close stubs on window and wires them to Luna events.
// Registered in <head> so our luna:build listener fires BEFORE the Playworks bundle
// listeners (which synchronously dispatch luna:start inside their luna:build handler).
// One-time guards prevent double-calls:
// - gameReady: luna:build fires it once; mtg's onload="gameReady()" also fires it
// - gameStart: luna:start fires twice on non-MRAID networks (two Playworks listeners)
function buildLifecycleScript(): string {
return [
'<script>(function(){',
'if(typeof window.gameReady!=="function")window.gameReady=function(){};',
'if(typeof window.gameStart!=="function")window.gameStart=function(){};',
'if(typeof window.gameEnd!=="function")window.gameEnd=function(){};',
'if(typeof window.gameClose!=="function")window.gameClose=function(){};',
'var _grOnce=false,_gsOnce=false;',
'window.addEventListener("luna:build",function(){if(_grOnce)return;_grOnce=true;try{window.gameReady();}catch(e){}});',
'window.addEventListener("luna:start",function(){if(_gsOnce)return;_gsOnce=true;try{window.gameStart();}catch(e){}});',
'window.addEventListener("luna:ended",function(){try{window.gameEnd();}catch(e){}});',
'})();</script>',
].join('');
}
function buildMraidComplianceScript(): string {
return [
'<script>(function(){',
'var m=null,scene=null,viewable=true,exposed=true,volume=null;',
'function get(){return window.mraid||m;}',
'function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}',
'function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}',
'function onViewable(v){viewable=!!v;apply();}',
'function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}',
'function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}',
'function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}',
'function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}',
'window.__hplMraidUnload=unload;',
'function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}',
'function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}',
'window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};',
'if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}',
'setTimeout(setup,2000);',
'})();</script>',
].join('');
}
function buildCTAScript(network: string): string {
const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`;
// gameClose must fire before every redirect
const closeCall = `try{window.gameClose();}catch(e){}`;
let ctaLogic: string;
switch (network) {
case 'al':
case 'is':
// MRAID — mraid.js injected in <head>
ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`;
break;
case 'fb':
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`;
break;
case 'gg':
case 'ap':
// Google — exitapi.js injected in <head>
ctaLogic = `${closeCall}if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");`;
break;
case 'mo':
ctaLogic = `${closeCall}if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");`;
break;
case 'vu':
// Vungle — window.__VUNGLE__ flag set in body
ctaLogic = `${closeCall}try{parent.postMessage("download","*");}catch(e){}`;
break;
case 'mtg':
// Mintegral — onload="gameReady()" set on body
ctaLogic = `${closeCall}if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");`;
break;
case 'tt':
// TikTok — window.__TIKTOK__ flag set in body
ctaLogic = `${closeCall}if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");`;
break;
default:
ctaLogic = `${closeCall}window.open(o,"_blank");`;
}
return `<script>window.addEventListener("luna:build",(function(){Bridge.ready((function(){Luna.Unity.Playable.InstallFullGame=function(n,i){window.pi.logCta(),${urlSetup}${ctaLogic}}}))}));</script>`;
}
function createZip(srcFilePath: string, nameInZip: string, destZipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
// Remove existing zip if present (Compress-Archive would fail otherwise)
if (fs.existsSync(destZipPath)) {
try { fs.unlinkSync(destZipPath); } catch { /* ignore */ }
}
const psScript = [
'Add-Type -AssemblyName System.IO.Compression.FileSystem;',
`$zip=[System.IO.Compression.ZipFile]::Open('${destZipPath}','Create');`,
`[void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip,'${srcFilePath}','${nameInZip}');`,
'$zip.Dispose()',
].join(' ');
const proc = cp.spawn('powershell.exe', ['-NonInteractive', '-Command', psScript], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Zip failed (code ${code}): ${stderr.trim()}`));
}
});
proc.on('error', reject);
});
}
// ─── Webview UI ────────────────────────────────────────────────────────────────
function getHtml(): string {
const networks = [
{ tag: 'al', label: 'Applovin', note: 'HTML + mraid.js in head' },
{ tag: 'is', label: 'Ironsource', note: 'HTML + mraid.js in head' },
{ tag: 'fb', label: 'Facebook', note: 'HTML' },
{ tag: 'gg', label: 'Google Ads', note: 'ZIP + exitapi.js in head' },
{ tag: 'ap', label: 'Appreciate', note: 'ZIP + exitapi.js in head' },
{ tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' },
{ tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' },
{ tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' },
{ tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' },
];
const checkboxRows = networks.map(n => `
<tr>
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
<td><span class="net-note">${n.note}</span></td>
</tr>`).join('');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
${getToolWebviewStyles()}
.net-label { min-width: 100px; font-weight: 500; }
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
.net-note { font-size: 11px; opacity: 0.6; }
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
</style>
</head>
<body>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Playworks Converter</h2>
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="control-label">Source HTML (Playworks export)</div>
<div class="field-row">
<input id="src" type="hidden" />
<button id="pickSrc" class="secondary">Select File</button>
<span id="srcName" class="file-name">(no file)</span>
</div>
</div>
<div class="control-group">
<div class="control-label">Base Filename</div>
<div class="field-row">
<input id="baseName" type="text" placeholder="Base filename..." />
</div>
</div>
<div class="control-group">
<div class="control-label">Output Folder</div>
<div class="field-row">
<input id="outDir" type="text" placeholder="Output folder..." />
<button id="pickOut" class="secondary">Browse...</button>
</div>
</div>
<div class="control-group">
<div class="control-label">Networks to Generate</div>
<div class="toggle-row">
<button id="selectAll" class="secondary">Select All</button>
<button id="selectNone" class="secondary">Select None</button>
</div>
<div class="results-panel" style="margin-top:0;">
<table class="data-table">
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
<tbody>${checkboxRows}</tbody>
</table>
</div>
</div>
<div class="action-row">
<button id="convert" disabled>Convert</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
<div class="action-row">
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
</div>
</div>
</main>
<script>
const vscode = acquireVsCodeApi();
let outputDir = '';
const srcEl = document.getElementById('src');
const srcNameEl = document.getElementById('srcName');
const outEl = document.getElementById('outDir');
const baseNameEl = document.getElementById('baseName');
const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
}
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
document.getElementById('selectAll').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
updateConvertBtn();
});
document.getElementById('selectNone').addEventListener('click', () => {
document.querySelectorAll('.net-cb').forEach(c => c.checked = false);
updateConvertBtn();
});
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
outEl.addEventListener('input', updateConvertBtn);
baseNameEl.addEventListener('input', updateConvertBtn);
openOutBtn.addEventListener('click', () => {
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
});
function outputDisplayName(file) {
const parts = file.split(/[\\\\/]/).filter(Boolean);
return parts.slice(-2).join('/');
}
document.getElementById('convert').addEventListener('click', () => {
const networks = [...document.querySelectorAll('.net-cb')]
.filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = '';
openOutBtn.style.display = 'none';
outputPanel.classList.add('is-hidden');
convertBtn.disabled = true;
vscode.postMessage({
type: 'convert',
opts: {
sourcePath: srcEl.value,
outputDir: outEl.value,
baseName: baseNameEl.value,
networks,
},
});
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'sourcePicked') {
srcEl.value = msg.path;
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
baseNameEl.value = msg.baseName || '';
if (!outEl.value && msg.dir) {
outEl.value = msg.dir;
outputDir = msg.dir;
}
updateConvertBtn();
} else if (msg.type === 'outputPicked') {
outEl.value = msg.path;
outputDir = msg.path;
updateConvertBtn();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'convertDone') {
convertBtn.disabled = false;
updateConvertBtn();
if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const results = msg.results || [];
statusEl.classList.remove('is-busy');
const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
if (!results.length) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of results) {
const row = document.createElement('tr');
const net = document.createElement('td');
net.textContent = r.network;
const status = document.createElement('td');
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
const outputType = document.createElement('td');
const detail = document.createElement('td');
detail.className = 'mono wrap';
const error = document.createElement('td');
error.className = 'err wrap';
if (r.ok) {
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
detail.textContent = outputDisplayName(r.file);
} else {
outputType.textContent = '-';
detail.innerHTML = '<span class="muted">-</span>';
error.textContent = r.error || 'Unknown error';
}
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
row.append(net, status, outputType, detail, error);
tbody.appendChild(row);
}
if (ok > 0) {
outputDir = outEl.value;
openOutBtn.style.display = '';
}
}
});
</script>
</body>
</html>`;
}