2 Commits

23 changed files with 939 additions and 113 deletions

View File

@@ -2,7 +2,7 @@
"name": "hpl-toolbox", "name": "hpl-toolbox",
"displayName": "HPL Toolbox", "displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "0.1.6", "version": "0.1.7",
"publisher": "hesukastro", "publisher": "hesukastro",
"license": "UNLICENSED", "license": "UNLICENSED",
"repository": { "repository": {

11
run-standalone.ps1 Normal file
View File

@@ -0,0 +1,11 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$standalone = Join-Path $root 'standalone'
Push-Location $standalone
try {
go run .
} finally {
Pop-Location
}

View File

@@ -1,15 +1,18 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled'; const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled';
interface ToolDefinition { interface ToolDefinition {
id?: string;
command: string; command: string;
title: string; title: string;
description: string; description: string;
beta?: boolean; beta?: boolean;
} }
const TOOLS: ToolDefinition[] = [ const FALLBACK_TOOLS: ToolDefinition[] = [
{ {
command: 'hplToolbox.openPlecUpload', command: 'hplToolbox.openPlecUpload',
title: 'PLEC Upload', title: 'PLEC Upload',
@@ -25,17 +28,16 @@ const TOOLS: ToolDefinition[] = [
title: 'Base64 Scanner', title: 'Base64 Scanner',
description: 'Find non-base64 assets in HTML', description: 'Find non-base64 assets in HTML',
}, },
{
command: 'hplToolbox.openMraidChecker',
title: 'MRAID Checker',
description: 'Check MRAID requirements and best practices',
beta: true,
},
{ {
command: 'hplToolbox.openSendToMobile', command: 'hplToolbox.openSendToMobile',
title: 'Send To Mobile', title: 'Send To Mobile',
description: 'Share HTML to a phone via LAN + QR', description: 'Share HTML to a phone via LAN + QR',
}, },
{
command: 'hplToolbox.openMraidChecker',
title: 'MRAID Checker',
description: 'Check MRAID requirements and best practices',
},
{ {
command: 'hplToolbox.openPlayworksConverter', command: 'hplToolbox.openPlayworksConverter',
title: 'Playworks Converter', title: 'Playworks Converter',
@@ -50,8 +52,9 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
resolveWebviewView(view: vscode.WebviewView) { resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true }; view.webview.options = { enableScripts: true };
const version = this.context.extension.packageJSON.version as string; const version = this.context.extension.packageJSON.version as string;
const tools = loadToolDefinitions(this.context.extensionPath);
const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false); const betaToolsEnabled = this.context.globalState.get<boolean>(BETA_TOOLS_ENABLED_KEY, false);
view.webview.html = getHtml(version, betaToolsEnabled); view.webview.html = getHtml(version, betaToolsEnabled, tools);
view.webview.onDidReceiveMessage(async (msg) => { view.webview.onDidReceiveMessage(async (msg) => {
if (msg?.type === 'open' && typeof msg.command === 'string') { if (msg?.type === 'open' && typeof msg.command === 'string') {
vscode.commands.executeCommand(msg.command); vscode.commands.executeCommand(msg.command);
@@ -59,14 +62,34 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
vscode.commands.executeCommand('hplToolbox.openChangelog'); vscode.commands.executeCommand('hplToolbox.openChangelog');
} else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') { } else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') {
await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled); await this.context.globalState.update(BETA_TOOLS_ENABLED_KEY, msg.enabled);
view.webview.html = getHtml(version, msg.enabled); view.webview.html = getHtml(version, msg.enabled, tools);
} }
}); });
} }
} }
function getHtml(version: string, betaToolsEnabled: boolean): string { function loadToolDefinitions(extensionPath: string): ToolDefinition[] {
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta)); const toolsPath = path.join(extensionPath, 'tools.json');
try {
const raw = JSON.parse(fs.readFileSync(toolsPath, 'utf8'));
if (!Array.isArray(raw)) return FALLBACK_TOOLS;
const tools = raw
.filter((tool: any) => tool && typeof tool.command === 'string' && typeof tool.title === 'string')
.map((tool: any) => ({
id: typeof tool.id === 'string' ? tool.id : undefined,
command: tool.command,
title: tool.title,
description: typeof tool.description === 'string' ? tool.description : '',
beta: Boolean(tool.beta),
}));
return tools.length ? tools : FALLBACK_TOOLS;
} catch {
return FALLBACK_TOOLS;
}
}
function getHtml(version: string, betaToolsEnabled: boolean, tools: ToolDefinition[]): string {
const visibleTools = sortToolsForDisplay(tools.filter(tool => betaToolsEnabled || !tool.beta));
const toolButtons = visibleTools.map(renderToolButton).join('\n'); const toolButtons = visibleTools.map(renderToolButton).join('\n');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>

View File

@@ -134,11 +134,11 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; } if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
if (i > 0 && delayMs > 0) { if (i > 0 && delayMs > 0) {
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Waiting ${delayMs}ms before ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Waiting ${idx}/${paths.length} ${fileName}` });
await sleep(delayMs); await sleep(delayMs);
} }
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Validating ${idx}/${paths.length} ${fileName}` });
let res: Response; let res: Response;
try { try {
@@ -154,7 +154,7 @@ async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue; reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
} }
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` }); panel.webview.postMessage({ type: 'status', message: `Uploading ${idx}/${paths.length} ${fileName}` });
try { try {
res = await fetch('https://p.applov.in/getCachedAdURL', { res = await fetch('https://p.applov.in/getCachedAdURL', {
@@ -460,6 +460,7 @@ ${getToolWebviewStyles()}
uploadBtn.addEventListener('click', () => { uploadBtn.addEventListener('click', () => {
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
if (!selectedPaths.length) { if (!selectedPaths.length) {
@@ -467,6 +468,8 @@ ${getToolWebviewStyles()}
return; return;
} }
uploadBtn.disabled = true; uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = []; resultItems = [];
saveAllBtn.disabled = true; saveAllBtn.disabled = true;
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
@@ -504,8 +507,9 @@ ${getToolWebviewStyles()}
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
} else if (m.type === 'status') { } else if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.className = 'status-panel'; statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'err'; div.className = 'err';
div.textContent = m.message; div.textContent = m.message;
@@ -558,6 +562,7 @@ ${getToolWebviewStyles()}
saveAllBtn.disabled = resultItems.length === 0; saveAllBtn.disabled = resultItems.length === 0;
regenAllBtn.disabled = resultItems.length === 0; regenAllBtn.disabled = resultItems.length === 0;
} else if (m.type === 'done') { } else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>'; statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false; uploadBtn.disabled = false;
} }

View File

@@ -58,7 +58,9 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
return; return;
} }
const results: { file: string; ok: boolean; assets: string[] }[] = []; const results: { file: string; ok: boolean; assets: string[] }[] = [];
for (const file of targets) { for (let i = 0; i < targets.length; i++) {
const file = targets[i];
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
try { try {
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
const offenders = findNonBase64Assets(content); const offenders = findNonBase64Assets(content);
@@ -279,11 +281,13 @@ ${getToolWebviewStyles()}
selectedPage = 0; selectedPage = 0;
renderSelection(); renderSelection();
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
}); });
document.getElementById('scan').addEventListener('click', () => { document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
if (pickedFiles.length) { if (pickedFiles.length) {
@@ -310,13 +314,18 @@ ${getToolWebviewStyles()}
pickedFolder = ''; pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'results') { } else if (msg.type === 'results') {
if (msg.error) { if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error; statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
return; return;
} }
const total = msg.results.length; const total = msg.results.length;
statusEl.classList.remove('is-busy');
const flagged = msg.results.filter(r => !r.ok).length; const flagged = msg.results.filter(r => !r.ok).length;
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.'; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) { if (!total) {

View File

@@ -116,7 +116,9 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
} }
const results: MraidResult[] = []; const results: MraidResult[] = [];
for (const file of targets) { for (let i = 0; i < targets.length; i++) {
const file = targets[i];
panel.webview.postMessage({ type: 'status', message: `Scanning ${i + 1}/${targets.length} ${path.basename(file)}` });
try { try {
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
const issues = checkMraid(content, file); const issues = checkMraid(content, file);
@@ -796,11 +798,13 @@ ${getToolWebviewStyles()}
selectedPage = 0; selectedPage = 0;
renderSelection(); renderSelection();
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
}); });
document.getElementById('scan').addEventListener('click', () => { document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
if (pickedFiles.length) { if (pickedFiles.length) {
@@ -827,13 +831,18 @@ ${getToolWebviewStyles()}
pickedFolder = ''; pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'results') { } else if (msg.type === 'results') {
if (msg.error) { if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error; statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
return; return;
} }
const total = msg.results.length; const total = msg.results.length;
statusEl.classList.remove('is-busy');
const withIssues = msg.results.filter(r => !r.ok).length; const withIssues = msg.results.filter(r => !r.ok).length;
const skipped = Number(msg.skipped || 0); const skipped = Number(msg.skipped || 0);
const skippedReason = msg.skippedReason || 'file(s).'; const skippedReason = msg.skippedReason || 'file(s).';

View File

@@ -68,6 +68,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
let html: string; let html: string;
try { try {
panel.webview.postMessage({ type: 'status', message: 'Reading source HTML...' });
html = fs.readFileSync(opts.sourcePath, 'utf8'); html = fs.readFileSync(opts.sourcePath, 'utf8');
} catch (e: any) { } catch (e: any) {
panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` }); panel.webview.postMessage({ type: 'convertDone', results: [], error: `Could not read source: ${e.message}` });
@@ -76,11 +77,14 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
const results: NetworkResult[] = []; const results: NetworkResult[] = [];
try { try {
panel.webview.postMessage({ type: 'status', message: 'Converting Unity...' });
results.push({ network: 'un', file: copyUnityInput(opts), ok: true }); results.push({ network: 'un', file: copyUnityInput(opts), ok: true });
} catch (e: any) { } catch (e: any) {
results.push({ network: 'un', file: '', ok: false, error: e.message }); results.push({ network: 'un', file: '', ok: false, error: e.message });
} }
for (const network of opts.networks) { 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 { try {
const filePath = await convertNetwork(html, opts, network); const filePath = await convertNetwork(html, opts, network);
results.push({ network, file: filePath, ok: true }); results.push({ network, file: filePath, ok: true });
@@ -569,6 +573,7 @@ ${getToolWebviewStyles()}
const networks = [...document.querySelectorAll('.net-cb')] const networks = [...document.querySelectorAll('.net-cb')]
.filter(c => c.checked).map(c => c.dataset.tag); .filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...'; statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
openOutBtn.style.display = 'none'; openOutBtn.style.display = 'none';
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
@@ -599,15 +604,20 @@ ${getToolWebviewStyles()}
outEl.value = msg.path; outEl.value = msg.path;
outputDir = msg.path; outputDir = msg.path;
updateConvertBtn(); updateConvertBtn();
} else if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.className = 'status-panel is-busy';
} else if (msg.type === 'convertDone') { } else if (msg.type === 'convertDone') {
convertBtn.disabled = false; convertBtn.disabled = false;
updateConvertBtn(); updateConvertBtn();
if (msg.error) { if (msg.error) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + msg.error; statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden'); outputPanel.classList.add('is-hidden');
return; return;
} }
const results = msg.results || []; const results = msg.results || [];
statusEl.classList.remove('is-busy');
const ok = results.filter(r => r.ok).length; const ok = results.filter(r => r.ok).length;
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
if (!results.length) { if (!results.length) {

View File

@@ -101,11 +101,13 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
const r = valid[i]; const r = valid[i];
const idx = i + 1; const idx = i + 1;
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` });
const buf = fs.readFileSync(r.path); const buf = fs.readFileSync(r.path);
const file = new File([buf], stampedNames[i], { type: 'text/html' }); const file = new File([buf], stampedNames[i], { type: 'text/html' });
form.append(`fileUpload_${idx}`, file as any); form.append(`fileUpload_${idx}`, file as any);
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, ''))); form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
} }
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
let res: Response; let res: Response;
try { try {
@@ -392,11 +394,14 @@ ${getToolWebviewStyles()}
document.getElementById('upload').addEventListener('click', () => { document.getElementById('upload').addEventListener('click', () => {
clearErrors(); clearErrors();
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
setPreview(''); setPreview('');
if (rows.length === 0) { if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Select at least one file.</span>'; statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return; return;
} }
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'upload', rows }); vscode.postMessage({ type: 'upload', rows });
}); });
openResultBtn.addEventListener('click', () => { openResultBtn.addEventListener('click', () => {
@@ -417,19 +422,22 @@ ${getToolWebviewStyles()}
addFiles(m.files || []); addFiles(m.files || []);
} else if (m.type === 'status') { } else if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.className = 'status-panel'; statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = ''; statusEl.innerHTML = '';
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'err'; div.className = 'err';
div.textContent = m.message; div.textContent = m.message;
statusEl.appendChild(div); statusEl.appendChild(div);
} else if (m.type === 'rowError') { } else if (m.type === 'rowError') {
statusEl.classList.remove('is-busy');
const row = rows.find(r => r.id === m.rowId); const row = rows.find(r => r.id === m.rowId);
if (row) row.error = m.message; if (row) row.error = m.message;
renderRows(); renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>'; statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
} else if (m.type === 'result') { } else if (m.type === 'result') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>'; statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
setPreview(m.preview); setPreview(m.preview);
} }

View File

@@ -103,13 +103,16 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
return; return;
} }
const files: SharedFile[] = []; const files: SharedFile[] = [];
for (const filePath of filePaths) { for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!filePath || !fs.existsSync(filePath)) { if (!filePath || !fs.existsSync(filePath)) {
panel.webview.postMessage({ type: 'error', message: 'File missing.' }); panel.webview.postMessage({ type: 'error', message: 'File missing.' });
return; return;
} }
panel.webview.postMessage({ type: 'status', message: `Loading ${i + 1}/${filePaths.length} ${path.basename(filePath)}` });
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) }); files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
} }
panel.webview.postMessage({ type: 'status', message: 'Starting server...' });
const token = crypto.randomBytes(6).toString('base64url'); const token = crypto.randomBytes(6).toString('base64url');
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile'); const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
@@ -421,6 +424,7 @@ ${getToolWebviewStyles()}
selectedPage = 0; selectedPage = 0;
renderSelection(); renderSelection();
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
}); });
startBtn.addEventListener('click', () => { startBtn.addEventListener('click', () => {
@@ -432,6 +436,7 @@ ${getToolWebviewStyles()}
} }
startBtn.disabled = true; startBtn.disabled = true;
statusEl.textContent = 'Starting server...'; statusEl.textContent = 'Starting server...';
statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'startShare', paths: selectedPaths }); vscode.postMessage({ type: 'startShare', paths: selectedPaths });
}); });
@@ -547,7 +552,11 @@ ${getToolWebviewStyles()}
selectedPaths = selectedFiles.map(f => f.path); selectedPaths = selectedFiles.map(f => f.path);
selectedPage = 0; selectedPage = 0;
renderSelection(); renderSelection();
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'sharing') { } else if (m.type === 'sharing') {
statusEl.classList.remove('is-busy');
currentUrls = m.urls; currentUrls = m.urls;
selectedIdx = 0; selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>'; statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
@@ -558,6 +567,7 @@ ${getToolWebviewStyles()}
startBtn.disabled = true; startBtn.disabled = true;
stopBtn.disabled = false; stopBtn.disabled = false;
} else if (m.type === 'stopped') { } else if (m.type === 'stopped') {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.'; statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none'; shareInfo.style.display = 'none';
ifaceWrap.style.display = 'none'; ifaceWrap.style.display = 'none';
@@ -566,6 +576,7 @@ ${getToolWebviewStyles()}
startBtn.disabled = false; startBtn.disabled = false;
stopBtn.disabled = true; stopBtn.disabled = true;
} else if (m.type === 'error') { } else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>'; statusEl.innerHTML = '<span class="err">' + (m.message || 'Error').replace(/</g, '&lt;') + '</span>';
startBtn.disabled = false; startBtn.disabled = false;
} }

View File

@@ -207,6 +207,24 @@ export function getToolWebviewStyles(): string {
padding: 8px 10px; padding: 8px 10px;
white-space: pre-wrap; white-space: pre-wrap;
} }
.status-panel.is-busy {
display: flex;
align-items: center;
gap: 8px;
}
.status-panel.is-busy::before {
content: "";
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 2px solid color-mix(in srgb, var(--vscode-descriptionForeground) 45%, transparent);
border-top-color: var(--vscode-foreground);
border-radius: 50%;
animation: status-spin 800ms linear infinite;
}
@keyframes status-spin {
to { transform: rotate(360deg); }
}
.status-panel:empty { .status-panel:empty {
display: none; display: none;
} }

View File

@@ -44,10 +44,13 @@ func ApplovinPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="file-row"> <div id="dropZone" class="drop-zone">
<button id="pickFolder" class="secondary">Select Folder</button> <div class="file-row">
<button id="pick" class="secondary">Select File(s)</button> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="clear" class="secondary">Clear</button> <button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div> </div>
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div> <div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-files"></div> <div id="fileList" class="selected-files"></div>
@@ -154,6 +157,15 @@ regenAllBtn.addEventListener('click', () => {
img.src = base + '&_=' + Date.now(); img.src = base + '&_=' + Date.now();
}); });
}); });
setupDropZone('dropZone', statusEl, (j) => {
if (j.files && j.files.length) {
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
selectedPaths = selectedPaths.concat(added);
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
renderSelection();
}
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
clearBtn.addEventListener('click', () => { clearBtn.addEventListener('click', () => {
selectedPaths = []; selectedPaths = [];
@@ -184,12 +196,15 @@ pickBtn.addEventListener('click', async () => {
uploadBtn.addEventListener('click', async () => { uploadBtn.addEventListener('click', async () => {
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!selectedPaths.length) { if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>'; statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return; return;
} }
uploadBtn.disabled = true; uploadBtn.disabled = true;
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
resultItems = []; resultItems = [];
saveAllBtn.disabled = true; saveAllBtn.disabled = true;
regenAllBtn.disabled = true; regenAllBtn.disabled = true;
@@ -200,6 +215,7 @@ uploadBtn.addEventListener('click', async () => {
body: JSON.stringify({ paths: selectedPaths }), body: JSON.stringify({ paths: selectedPaths }),
}); });
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">Upload failed.</span>'; statusEl.innerHTML = '<span class="err">Upload failed.</span>';
uploadBtn.disabled = false; uploadBtn.disabled = false;
return; return;
@@ -219,6 +235,7 @@ uploadBtn.addEventListener('click', async () => {
} }
} }
} catch (e) { } catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>'; statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
} finally { } finally {
uploadBtn.disabled = false; uploadBtn.disabled = false;
@@ -240,6 +257,7 @@ qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
function handle(m) { function handle(m) {
if (m.type === 'status') { if (m.type === 'status') {
statusEl.textContent = m.message; statusEl.textContent = m.message;
statusEl.classList.add('is-busy');
} else if (m.type === 'fileError') { } else if (m.type === 'fileError') {
const tbody = ensureResultsTable(); const tbody = ensureResultsTable();
resultsEl.querySelector('table').classList.add('has-errors'); resultsEl.querySelector('table').classList.add('has-errors');
@@ -296,6 +314,7 @@ function handle(m) {
saveAllBtn.disabled = resultItems.length === 0; saveAllBtn.disabled = resultItems.length === 0;
regenAllBtn.disabled = resultItems.length === 0; regenAllBtn.disabled = resultItems.length === 0;
} else if (m.type === 'done') { } else if (m.type === 'done') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Done.</span>'; statusEl.innerHTML = '<span class="ok">Done.</span>';
} }
} }
@@ -432,11 +451,11 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
} }
if i > 0 && delayMs > 0 { if i > 0 && delayMs > 0 {
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Waiting %dms before %s...", idx, len(req.Paths), delayMs, fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)})
time.Sleep(time.Duration(delayMs) * time.Millisecond) time.Sleep(time.Duration(delayMs) * time.Millisecond)
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Validating %s...", idx, len(req.Paths), fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)})
body, ct, err := buildForm(filePath) body, ct, err := buildForm(filePath)
if err != nil { if err != nil {
@@ -461,7 +480,7 @@ func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
} }
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("[%d/%d] Uploading %s...", idx, len(req.Paths), fileName)}) send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d/%d %s", idx, len(req.Paths), fileName)})
body, ct, err = buildForm(filePath) body, ct, err = buildForm(filePath)
if err != nil { if err != nil {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@@ -21,10 +22,13 @@ func Base64Page(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="file-row"> <div id="dropZone" class="drop-zone">
<button id="pickFolder" class="secondary">Select Folder</button> <div class="file-row">
<button id="pickFiles" class="secondary">Select File(s)</button> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="clear" class="secondary">Clear</button> <button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div> </div>
<div id="selection" class="selected-list"></div> <div id="selection" class="selected-list"></div>
</div> </div>
@@ -86,7 +90,29 @@ function addPaths(paths) {
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} }
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection(); renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addPaths(j.paths || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' }); const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
@@ -102,19 +128,39 @@ document.getElementById('clear').addEventListener('click', () => {
pickedFiles = []; pickedFiles = [];
selectedPage = 0; selectedPage = 0;
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
renderSelection(); renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; } if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles }; const payload = { files: pickedFiles };
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); let j = null;
const j = await r.json(); try {
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
await readJSONStream(r, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + (e.message || e);
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length; const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length; const flagged = j.results.filter(r => !r.ok).length;
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.'; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) { resultsEl.innerHTML = ''; return; } if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>'; resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead><tbody></tbody></table>';
@@ -158,12 +204,13 @@ type scanResult struct {
} }
func Base64Scan(w http.ResponseWriter, r *http.Request) { func Base64Scan(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Folder string `json:"folder"` Folder string `json:"folder"`
Files []string `json:"files"` Files []string `json:"files"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
@@ -183,12 +230,13 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
targets = collectHTMLFiles(req.Folder) targets = collectHTMLFiles(req.Folder)
baseDir = req.Folder baseDir = req.Folder
} else { } else {
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"}) send(map[string]any{"type": "error", "message": "Pick a folder or files first"})
return return
} }
results := []scanResult{} results := []scanResult{}
for _, file := range targets { for i, file := range targets {
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
data, err := os.ReadFile(file) data, err := os.ReadFile(file)
if err != nil { if err != nil {
continue continue
@@ -209,7 +257,7 @@ func Base64Scan(w http.ResponseWriter, r *http.Request) {
Assets: offenders, Assets: offenders,
}) })
} }
writeJSON(w, map[string]any{"results": results}) send(map[string]any{"type": "done", "results": results})
} }
func commonBaseDir(files []string) string { func commonBaseDir(files []string) string {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
) )
@@ -9,3 +10,27 @@ func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
func newJSONStream(w http.ResponseWriter) func(any) {
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
flusher, _ := w.(http.Flusher)
return func(v any) {
data, err := json.Marshal(v)
if err != nil {
data = []byte(fmt.Sprintf(`{"type":"error","message":%q}`, err.Error()))
}
_, _ = w.Write(data)
_, _ = w.Write([]byte("\n"))
if flusher != nil {
flusher.Flush()
}
}
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}

View File

@@ -2,8 +2,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/url"
"os"
"path/filepath"
"strings" "strings"
"time"
) )
const SharedCSS = ` const SharedCSS = `
@@ -107,6 +112,9 @@ const SharedCSS = `
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); } .status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); }
.status-panel { min-height: 30px; margin-top: var(--tool-gap-md); padding: 8px 10px; white-space: pre-wrap; } .status-panel { min-height: 30px; margin-top: var(--tool-gap-md); padding: 8px 10px; white-space: pre-wrap; }
.status-panel.is-busy { display: flex; align-items: center; gap: 8px; }
.status-panel.is-busy::before { content: ""; width: 12px; height: 12px; flex: 0 0 auto; border: 2px solid rgba(167,167,167,0.35); border-top-color: #ddd; border-radius: 50%; animation: status-spin 800ms linear infinite; }
@keyframes status-spin { to { transform: rotate(360deg); } }
.status-panel:empty { display: none; } .status-panel:empty { display: none; }
.results-panel { margin-top: var(--tool-gap-md); overflow: auto; } .results-panel { margin-top: var(--tool-gap-md); overflow: auto; }
.results-panel:empty { display: none; } .results-panel:empty { display: none; }
@@ -118,6 +126,28 @@ const SharedCSS = `
.mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; } .mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; }
.wrap { overflow-wrap: anywhere; word-break: break-word; } .wrap { overflow-wrap: anywhere; word-break: break-word; }
.file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; } .file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; }
.drop-zone {
margin-top: 8px;
min-height: 44px;
display: grid;
justify-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px dashed #444;
border-radius: var(--tool-radius);
color: #a7a7a7;
background: rgba(128,128,128,0.08);
font-size: 12px;
text-align: center;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.drop-zone .file-row { justify-content: center; }
.drop-zone-text { color: #a7a7a7; font-size: 12px; }
.drop-zone.is-dragover {
border-color: #007fd4;
color: #ddd;
background: rgba(0,127,212,0.12);
}
.selected-list, .selected-files { margin-top: 8px; } .selected-list, .selected-files { margin-top: 8px; }
.remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; } .remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; } .pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
@@ -131,6 +161,160 @@ const SharedCSS = `
.hint { font-size: 12px; opacity: 0.7; } .hint { font-size: 12px; opacity: 0.7; }
` `
const SharedDropZoneScript = `
function extractDroppedPaths(dataTransfer) {
const paths = [];
const add = (value) => {
if (!value) return;
const trimmed = String(value).trim();
if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
};
const addPathText = (text) => {
const value = String(text || '');
if (!value) return;
const uriMatches = value.match(/file:\/\/(?:\/[A-Za-z]:)?[^\\r\\n\\0]+/gi) || [];
uriMatches.forEach(add);
const quotedMatches = value.match(/"([^"]+)"|'([^']+)'/g) || [];
quotedMatches.forEach(match => add(match.replace(/^["']|["']$/g, '')));
value.split(/[\r\n\0]+/).forEach(line => {
let rest = line.trim();
if (!rest || rest.startsWith('#')) return;
while (rest.length) {
const driveMatches = [...rest.matchAll(/[A-Za-z]:\\/g)].map(m => m.index).filter(i => typeof i === 'number');
if (driveMatches.length <= 1) {
add(rest);
break;
}
const next = driveMatches[1];
add(rest.slice(0, next).trim());
rest = rest.slice(next).trim();
}
});
};
const addLines = (text) => {
addPathText(text);
};
try { addLines(dataTransfer.getData('text/uri-list')); } catch {}
try { addLines(dataTransfer.getData('text/plain')); } catch {}
for (const file of Array.from(dataTransfer.files || [])) {
add(file.path);
}
return paths;
}
async function extractDroppedHtmlFiles(dataTransfer) {
const files = [];
const seen = new Set();
const droppedFiles = Array.from(dataTransfer.files || []);
const entries = [];
const handlePromises = [];
for (const item of Array.from(dataTransfer.items || [])) {
if (typeof item.getAsFileSystemHandle === 'function') {
handlePromises.push(item.getAsFileSystemHandle().catch(() => null));
}
let entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
if (!entry && typeof item.getAsEntry === 'function') entry = item.getAsEntry();
if (entry) entries.push(entry);
}
const addFile = async (file, name) => {
const fileName = name || file.name || 'dropped.html';
if (!/\.html?$/i.test(fileName)) return;
const key = fileName + ':' + file.size + ':' + file.lastModified;
if (seen.has(key)) return;
seen.add(key);
files.push({ name: fileName, content: await file.text() });
};
const readEntry = async (entry, prefix) => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(async (file) => {
await addFile(file, (prefix || '') + file.name);
resolve();
}, () => resolve());
});
return;
}
if (entry.isDirectory) {
const reader = entry.createReader();
while (true) {
const entries = await new Promise(resolve => reader.readEntries(resolve, () => resolve([])));
if (!entries.length) break;
for (const child of entries) await readEntry(child, (prefix || '') + entry.name + '/');
}
}
};
const readHandle = async (handle, prefix) => {
if (!handle) return;
if (handle.kind === 'file') {
try {
const file = await handle.getFile();
await addFile(file, (prefix || '') + file.name);
} catch {}
return;
}
if (handle.kind === 'directory') {
try {
for await (const child of handle.values()) {
await readHandle(child, (prefix || '') + handle.name + '/');
}
} catch {}
}
};
for (const file of droppedFiles) await addFile(file, file.name);
for (const entry of entries) await readEntry(entry, '');
for (const handle of await Promise.all(handlePromises)) await readHandle(handle, '');
return files;
}
function setupDropZone(id, statusElement, onResolved) {
const zone = document.getElementById(id);
if (!zone) return;
const fallback = 'This view could not read dropped file paths. Use Select File(s) or Select Folder instead.';
const setMessage = (text) => {
if (statusElement) statusElement.textContent = text || '';
};
['dragenter', 'dragover'].forEach(type => {
zone.addEventListener(type, (event) => {
if (event.target && event.target.closest && event.target.closest('button, input')) return;
event.preventDefault();
event.stopPropagation();
zone.classList.add('is-dragover');
});
});
['dragleave', 'dragend'].forEach(type => {
zone.addEventListener(type, (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
});
});
zone.addEventListener('drop', async (event) => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragover');
const paths = extractDroppedPaths(event.dataTransfer);
const files = await extractDroppedHtmlFiles(event.dataTransfer);
if (!paths.length && !files.length) {
setMessage(fallback);
return;
}
setMessage('');
try {
const r = await fetch('/api/drop/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths, files }),
});
const j = await r.json();
if (typeof onResolved === 'function') onResolved(j);
} catch (e) {
setMessage('Could not read dropped items: ' + (e.message || e));
}
});
}
`
type navItem struct { type navItem struct {
Path string Path string
Label string Label string
@@ -143,8 +327,8 @@ var navItems = []navItem{
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
{Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"},
{Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices", Beta: true},
{Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"}, {Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"},
{Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"},
{Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true}, {Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true},
} }
@@ -207,6 +391,7 @@ func Page(activePath, title, body string) string {
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>` + title + ` — HPL Toolbox</title> <title>` + title + ` — HPL Toolbox</title>
<style>` + SharedCSS + `</style> <style>` + SharedCSS + `</style>
<script>` + SharedDropZoneScript + `</script>
</head><body> </head><body>
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div> <div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
<div class="content">` + body + `</div> <div class="content">` + body + `</div>
@@ -242,6 +427,153 @@ func boolAttr(v bool) string {
return "false" return "false"
} }
func DropResolveEndpoint(w http.ResponseWriter, r *http.Request) {
type droppedFile struct {
Name string `json:"name"`
Content string `json:"content"`
}
var req struct {
Paths []string `json:"paths"`
Files []droppedFile `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"files": []any{}, "paths": []string{}, "skipped": 0, "error": err.Error()})
return
}
seen := map[string]bool{}
var outPaths []string
skipped := 0
addFile := func(p string) {
if !isHTMLPath(p) {
skipped++
return
}
key := strings.ToLower(filepath.Clean(p))
if seen[key] {
return
}
seen[key] = true
outPaths = append(outPaths, p)
}
for _, raw := range req.Paths {
p := normalizeDroppedPath(raw)
if p == "" {
skipped++
continue
}
info, err := os.Stat(p)
if err != nil {
skipped++
continue
}
if info.IsDir() {
for _, filePath := range collectHTMLFiles(p) {
addFile(filePath)
}
continue
}
if info.Mode().IsRegular() {
addFile(p)
continue
}
skipped++
}
if len(req.Files) > 0 {
cleanupStaleStandaloneDropDirs()
dir, err := os.MkdirTemp("", "hpltoolbox-drop-")
if err != nil {
skipped += len(req.Files)
} else {
for _, file := range req.Files {
if !isHTMLPath(file.Name) {
skipped++
continue
}
name := safeStandaloneDropName(file.Name)
target := filepath.Join(dir, name)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; fileExists(target); i++ {
target = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext))
}
if err := os.WriteFile(target, []byte(file.Content), 0644); err != nil {
skipped++
continue
}
addFile(target)
}
}
}
files := make([]map[string]string, 0, len(outPaths))
for _, p := range outPaths {
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
}
writeJSON(w, map[string]any{"files": files, "paths": outPaths, "skipped": skipped})
}
func safeStandaloneDropName(name string) string {
base := filepath.Base(name)
base = strings.Map(func(r rune) rune {
switch r {
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
return '_'
default:
if r < 32 {
return '_'
}
return r
}
}, base)
base = strings.TrimSpace(base)
if base == "" {
return "dropped.html"
}
return base
}
func cleanupStaleStandaloneDropDirs() {
root := os.TempDir()
entries, err := os.ReadDir(root)
if err != nil {
return
}
cutoff := time.Now().Add(-24 * time.Hour)
for _, entry := range entries {
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "hpltoolbox-drop-") {
continue
}
dir := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err == nil && info.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
}
}
func normalizeDroppedPath(value string) string {
result := strings.Trim(strings.TrimSpace(value), `"'`)
if strings.HasPrefix(strings.ToLower(result), "file://") {
result = strings.TrimPrefix(result, "file://")
if decoded, err := url.PathUnescape(result); err == nil {
result = decoded
}
if len(result) >= 3 && result[0] == '/' && result[2] == ':' {
result = result[1:]
}
result = filepath.FromSlash(result)
}
return result
}
func isHTMLPath(p string) bool {
ext := strings.ToLower(filepath.Ext(p))
return ext == ".html" || ext == ".htm"
}
func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) { func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`

View File

@@ -121,6 +121,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /api/open", OpenEndpoint) mux.HandleFunc("POST /api/open", OpenEndpoint)
mux.HandleFunc("POST /api/focus", FocusEndpoint) mux.HandleFunc("POST /api/focus", FocusEndpoint)
mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint) mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint)
mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint)
// PLEC // PLEC
mux.HandleFunc("POST /api/plec/pick", PlecPick) mux.HandleFunc("POST /api/plec/pick", PlecPick)

View File

@@ -67,10 +67,13 @@ func MobilePage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="file-row"> <div id="dropZone" class="drop-zone">
<button id="pickFolder" class="secondary">Select Folder</button> <div class="file-row">
<button id="pick" class="secondary">Select File(s)</button> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="clear" class="secondary">Clear</button> <button id="pick" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div> </div>
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div> <div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="fileList" class="selected-list"></div> <div id="fileList" class="selected-list"></div>
@@ -164,7 +167,29 @@ function addFiles(files) {
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1); selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} }
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection(); renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addFiles(j.files || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
pickFolderBtn.addEventListener('click', async () => { pickFolderBtn.addEventListener('click', async () => {
const r = await fetch('/api/mobile/pickFolder', { method:'POST' }); const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
@@ -181,6 +206,7 @@ clearBtn.addEventListener('click', () => {
selectedPaths = []; selectedPaths = [];
selectedPage = 0; selectedPage = 0;
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderSelection(); renderSelection();
}); });
@@ -192,17 +218,38 @@ startBtn.addEventListener('click', async () => {
} }
startBtn.disabled = true; startBtn.disabled = true;
statusEl.textContent = 'Starting server...'; statusEl.textContent = 'Starting server...';
const r = await fetch('/api/mobile/start', { statusEl.classList.add('is-busy');
method:'POST', headers:{'Content-Type':'application/json'}, let j = null;
body: JSON.stringify({ paths: selectedPaths }), try {
}); const r = await fetch('/api/mobile/start', {
const j = await r.json(); method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ paths: selectedPaths }),
});
await readJSONStream(r, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
startBtn.disabled = false;
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; startBtn.disabled = false; return; }
if (j.error) { if (j.error) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>'; statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
startBtn.disabled = false; startBtn.disabled = false;
return; return;
} }
currentUrls = j.urls; currentUrls = j.urls;
statusEl.classList.remove('is-busy');
selectedIdx = 0; selectedIdx = 0;
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>'; statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
shareInfo.style.display = ''; shareInfo.style.display = '';
@@ -213,6 +260,7 @@ startBtn.addEventListener('click', async () => {
stopBtn.addEventListener('click', async () => { stopBtn.addEventListener('click', async () => {
await fetch('/api/mobile/stop', { method:'POST' }); await fetch('/api/mobile/stop', { method:'POST' });
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Stopped.'; statusEl.textContent = 'Stopped.';
shareInfo.style.display = 'none'; shareInfo.style.display = 'none';
currentUrls = []; currentUrls = [];
@@ -291,13 +339,14 @@ func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
func MobileStart(w http.ResponseWriter, r *http.Request) { func MobileStart(w http.ResponseWriter, r *http.Request) {
stopActiveShare() stopActiveShare()
send := newJSONStream(w)
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Paths []string `json:"paths"` Paths []string `json:"paths"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
paths := req.Paths paths := req.Paths
@@ -305,18 +354,19 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
paths = []string{req.Path} paths = []string{req.Path}
} }
if len(paths) == 0 { if len(paths) == 0 {
writeJSON(w, map[string]any{"error": "File missing."}) send(map[string]any{"type": "error", "message": "File missing."})
return return
} }
files := make([]sharedFile, 0, len(paths)) files := make([]sharedFile, 0, len(paths))
for _, p := range paths { for i, p := range paths {
if p == "" || !fileExists(p) { if p == "" || !fileExists(p) {
writeJSON(w, map[string]any{"error": "File missing."}) send(map[string]any{"type": "error", "message": "File missing."})
return return
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("Loading %d/%d %s", i+1, len(paths), filepath.Base(p))})
fileBuf, err := os.ReadFile(p) fileBuf, err := os.ReadFile(p)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return return
} }
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)}) files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
@@ -325,15 +375,16 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
// 6 random bytes -> base64url, no padding // 6 random bytes -> base64url, no padding
tokenBytes := make([]byte, 6) tokenBytes := make([]byte, 6)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()}) send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()})
return return
} }
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=") token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
cfgPort := LoadConfig().SendToMobile.Port cfgPort := LoadConfig().SendToMobile.Port
send(map[string]any{"type": "status", "message": "Starting server..."})
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort)) listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()}) send(map[string]any{"type": "error", "message": "Failed to bind: " + err.Error()})
return return
} }
port := listener.Addr().(*net.TCPAddr).Port port := listener.Addr().(*net.TCPAddr).Port
@@ -351,7 +402,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
ips := getLanIPs() ips := getLanIPs()
if len(ips) == 0 { if len(ips) == 0 {
_ = share.server.Close() _ = share.server.Close()
writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"}) send(map[string]any{"type": "error", "message": "No non-loopback IPv4 interface found. Are you on a network?"})
return return
} }
@@ -376,7 +427,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
if len(files) == 1 { if len(files) == 1 {
filename = files[0].filename filename = files[0].filename
} }
writeJSON(w, map[string]any{"urls": urls, "filename": filename}) send(map[string]any{"type": "done", "urls": urls, "filename": filename})
} }
func MobileStop(w http.ResponseWriter, r *http.Request) { func MobileStop(w http.ResponseWriter, r *http.Request) {

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -50,10 +51,13 @@ func MraidPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="file-row"> <div id="dropZone" class="drop-zone">
<button id="pickFolder" class="secondary">Select Folder</button> <div class="file-row">
<button id="pickFiles" class="secondary">Select File(s)</button> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="clear" class="secondary">Clear</button> <button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div> </div>
<div id="selection" class="selected-list"></div> <div id="selection" class="selected-list"></div>
</div> </div>
@@ -128,7 +132,29 @@ function addPaths(paths) {
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1); selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection(); renderSelection();
} }
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection(); renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addPaths(j.paths || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' }); const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
@@ -144,17 +170,36 @@ document.getElementById('clear').addEventListener('click', () => {
pickedFiles = []; pickedFiles = [];
selectedPage = 0; selectedPage = 0;
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
renderSelection(); renderSelection();
}); });
document.getElementById('scan').addEventListener('click', async () => { document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...'; statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; } if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles }; const payload = { files: pickedFiles };
const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); let j = null;
const j = await r.json(); try {
if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } const r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
await readJSONStream(r, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message, results: msg.results || [], skipped: msg.skipped, skippedReason: msg.skippedReason };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + (e.message || e);
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length; const total = j.results.length;
const withIssues = j.results.filter(r => !r.ok).length; const withIssues = j.results.filter(r => !r.ok).length;
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0); const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
@@ -162,6 +207,7 @@ document.getElementById('scan').addEventListener('click', async () => {
const skipped = Number(j.skipped || 0); const skipped = Number(j.skipped || 0);
const skippedReason = j.skippedReason || 'file(s).'; const skippedReason = j.skippedReason || 'file(s).';
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : ''; const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) { resultsEl.innerHTML = ''; return; } if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>'; resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
@@ -218,12 +264,13 @@ func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
} }
func MraidScan(w http.ResponseWriter, r *http.Request) { func MraidScan(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Folder string `json:"folder"` Folder string `json:"folder"`
Files []string `json:"files"` Files []string `json:"files"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error(), "results": []mraidResult{}})
return return
} }
@@ -258,16 +305,17 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found." noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
baseDir = req.Folder baseDir = req.Folder
} else { } else {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"}) send(map[string]any{"type": "error", "message": "Pick a folder or files first", "results": []mraidResult{}})
return return
} }
if len(targets) == 0 { if len(targets) == 0 {
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError}) send(map[string]any{"type": "error", "message": noTargetsError, "results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason})
return return
} }
results := []mraidResult{} results := []mraidResult{}
for _, file := range targets { for i, file := range targets {
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
data, err := os.ReadFile(file) data, err := os.ReadFile(file)
if err != nil { if err != nil {
continue continue
@@ -283,7 +331,7 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
} }
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues}) results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
} }
writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason}) send(map[string]any{"type": "done", "results": results, "skipped": skipped, "skippedReason": skippedReason})
} }
var ( var (

View File

@@ -114,8 +114,7 @@ func SaveConfig(cfg AppConfig) error {
} }
func OpenInBrowser(url string) { func OpenInBrowser(url string) {
// `start` is a cmd builtin. Quoted empty arg is the window title. cmd := hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url)
cmd := hiddenCommand("cmd", "/c", "start", "", url)
_ = cmd.Start() _ = cmd.Start()
} }

View File

@@ -47,10 +47,15 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="control-label">Source HTML (Playworks export)</div> <div class="control-label">Source HTML (Playworks export)</div>
<div class="field-row"> <div id="dropZone" class="drop-zone">
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" /> <div class="file-row">
<button id="pickSrc" class="secondary">Select File</button> <button id="pickSrc" class="secondary">Select File</button>
<button id="clearSrc" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop a Playworks HTML file here</div>
</div> </div>
<input id="src" type="hidden" />
<div id="sourceSelection" class="selected-list"></div>
</div> </div>
<div class="control-group"> <div class="control-group">
@@ -106,22 +111,78 @@ const convertBtn = document.getElementById('convert');
const openOutBtn = document.getElementById('openOut'); const openOutBtn = document.getElementById('openOut');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results'); const resultsEl = document.getElementById('results');
const sourceSelectionEl = document.getElementById('sourceSelection');
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
function updateConvertBtn() { function updateConvertBtn() {
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked); const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet; convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
} }
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderSourceSelection() {
if (!srcEl.value) {
sourceSelectionEl.innerHTML = '<span class="file-name">(no file selected)</span>';
return;
}
sourceSelectionEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
'<tr><td class="mono wrap">' + escapeHtml(basename(srcEl.value)) + '</td><td><button id="removeSource" class="remove-selected danger" title="Remove">&times;</button></td></tr>' +
'</tbody></table></div>';
document.getElementById('removeSource').addEventListener('click', clearSource);
}
function setSource(path, baseName, dir) {
srcEl.value = path || '';
baseNameEl.value = baseName || '';
if (!outEl.value && dir) { outEl.value = dir.replace(/[\\\/]+$/, '') + '\\\\Output'; outputDir = outEl.value; }
renderSourceSelection();
updateConvertBtn();
}
function clearSource() {
srcEl.value = '';
baseNameEl.value = '';
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderSourceSelection();
updateConvertBtn();
}
document.getElementById('pickSrc').addEventListener('click', async () => { document.getElementById('pickSrc').addEventListener('click', async () => {
const r = await fetch('/api/playworks/pickSource', { method:'POST' }); const r = await fetch('/api/playworks/pickSource', { method:'POST' });
const j = await r.json(); const j = await r.json();
if (j.path) { if (j.path) {
srcEl.value = j.path; setSource(j.path, j.baseName || '', j.dir || '');
baseNameEl.value = j.baseName || '';
if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; }
updateConvertBtn();
} }
}); });
document.getElementById('clearSrc').addEventListener('click', clearSource);
document.getElementById('pickOut').addEventListener('click', async () => { document.getElementById('pickOut').addEventListener('click', async () => {
const r = await fetch('/api/playworks/pickOutput', { method:'POST' }); const r = await fetch('/api/playworks/pickOutput', { method:'POST' });
const j = await r.json(); const j = await r.json();
@@ -146,10 +207,25 @@ function outputDisplayName(file) {
const parts = file.split(/[\\\\/]/).filter(Boolean); const parts = file.split(/[\\\\/]/).filter(Boolean);
return parts.slice(-2).join('/'); return parts.slice(-2).join('/');
} }
setupDropZone('dropZone', statusEl, (j) => {
const paths = j.paths || [];
if (!paths.length) {
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
return;
}
const sourcePath = paths[0];
const droppedBase = basename(sourcePath).replace(/\\.html?$/i, '').replace(/_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$/i, '');
const i = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\\\'));
setSource(sourcePath, droppedBase, i >= 0 ? sourcePath.slice(0, i) : '');
const skipped = Number(j.skipped || 0) + Math.max(0, paths.length - 1);
if (skipped) statusEl.textContent = 'Using first dropped HTML file. Skipped ' + skipped + ' other item(s).';
});
renderSourceSelection();
document.getElementById('convert').addEventListener('click', async () => { document.getElementById('convert').addEventListener('click', async () => {
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag); const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
statusEl.textContent = 'Converting...'; statusEl.textContent = 'Converting...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
openOutBtn.disabled = true; openOutBtn.disabled = true;
convertBtn.disabled = true; convertBtn.disabled = true;
@@ -158,12 +234,28 @@ document.getElementById('convert').addEventListener('click', async () => {
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks }) body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks })
}); });
const msg = await res.json(); let msg = null;
try {
await readJSONStream(res, (event) => {
if (event.type === 'status') {
statusEl.textContent = event.message;
statusEl.classList.add('is-busy');
} else if (event.type === 'done') {
msg = event;
} else if (event.type === 'error') {
msg = { error: event.message, results: event.results || [] };
}
});
} catch (e) {
msg = { error: e.message || e, results: [] };
}
convertBtn.disabled = false; convertBtn.disabled = false;
updateConvertBtn(); updateConvertBtn();
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; } if (!msg) { msg = { error: 'No response.', results: [] }; }
if (msg.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; return; }
const results = msg.results || []; const results = msg.results || [];
const ok = results.filter(r => r.ok).length; const ok = results.filter(r => r.ok).length;
statusEl.classList.remove('is-busy');
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
if (!results.length) { resultsEl.innerHTML = ''; return; } if (!results.length) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>'; resultsEl.innerHTML = '<table class="data-table"><thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th>Output Path</th><th>Error</th></tr></thead><tbody></tbody></table>';
@@ -198,33 +290,37 @@ func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
} }
func PlayworksConvert(w http.ResponseWriter, r *http.Request) { func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var opts playworksConvertOptions var opts playworksConvertOptions
if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { if err := json.NewDecoder(r.Body).Decode(&opts); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error(), "results": []playworksNetworkResult{}})
return return
} }
if !fileExists(opts.SourcePath) { if !fileExists(opts.SourcePath) {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."}) send(map[string]any{"type": "error", "message": "Source file not found.", "results": []playworksNetworkResult{}})
return return
} }
if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { if err := os.MkdirAll(opts.OutputDir, 0755); err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."}) send(map[string]any{"type": "error", "message": "Could not create output folder.", "results": []playworksNetworkResult{}})
return return
} }
send(map[string]any{"type": "status", "message": "Reading source HTML..."})
data, err := os.ReadFile(opts.SourcePath) data, err := os.ReadFile(opts.SourcePath)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()}) send(map[string]any{"type": "error", "message": "Could not read source: " + err.Error(), "results": []playworksNetworkResult{}})
return return
} }
results := []playworksNetworkResult{} results := []playworksNetworkResult{}
send(map[string]any{"type": "status", "message": "Converting Unity..."})
unityPath, err := copyPlayworksUnityInput(data, opts) unityPath, err := copyPlayworksUnityInput(data, opts)
if err != nil { if err != nil {
results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()}) results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()})
} else { } else {
results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true}) results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true})
} }
for _, network := range opts.Networks { for i, network := range opts.Networks {
send(map[string]any{"type": "status", "message": fmt.Sprintf("Converting %d/%d %s", i+1, len(opts.Networks), network)})
filePath, err := convertPlayworksNetwork(string(data), opts, network) filePath, err := convertPlayworksNetwork(string(data), opts, network)
if err != nil { if err != nil {
results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()}) results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()})
@@ -232,7 +328,7 @@ func PlayworksConvert(w http.ResponseWriter, r *http.Request) {
} }
results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true}) results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true})
} }
writeJSON(w, map[string]any{"results": results}) send(map[string]any{"type": "done", "results": results})
} }
var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`) var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`)

View File

@@ -26,10 +26,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div class="file-row"> <div id="dropZone" class="drop-zone">
<button id="pickFolder" class="secondary">Select Folder</button> <div class="file-row">
<button id="pickFiles" class="secondary">Select File(s)</button> <button id="pickFolder" class="secondary">Select Folder</button>
<button id="clear" class="secondary">Clear</button> <button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</div> </div>
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div> <div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;"> <div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
@@ -97,6 +100,24 @@ function addFiles(files) {
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1); page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows(); renderRows();
} }
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
function renderRows() { function renderRows() {
const oldPager = document.getElementById('selectedPager'); const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove(); if (oldPager) oldPager.remove();
@@ -138,6 +159,10 @@ function setPreview(url) {
openResultBtn.disabled = !previewUrl; openResultBtn.disabled = !previewUrl;
copyResultBtn.disabled = !previewUrl; copyResultBtn.disabled = !previewUrl;
} }
setupDropZone('dropZone', statusEl, (j) => {
addFiles(j.files || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => { document.getElementById('pickFolder').addEventListener('click', async () => {
const res = await fetch('/api/plec/pickFolder', { method: 'POST' }); const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
@@ -154,6 +179,7 @@ document.getElementById('clear').addEventListener('click', () => {
page = 0; page = 0;
setPreview(''); setPreview('');
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
renderRows(); renderRows();
}); });
openResultBtn.addEventListener('click', () => { openResultBtn.addEventListener('click', () => {
@@ -167,19 +193,41 @@ document.getElementById('upload').addEventListener('click', async () => {
rows.forEach(row => row.error = ''); rows.forEach(row => row.error = '');
renderRows(); renderRows();
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.classList.remove('is-busy');
setPreview(''); setPreview('');
if (rows.length === 0) { if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Select at least one file.</span>'; statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return; return;
} }
statusEl.textContent = 'Uploading...'; statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', { statusEl.classList.add('is-busy');
method: 'POST', let j = null;
headers: { 'Content-Type': 'application/json' }, try {
body: JSON.stringify({ rows }), const res = await fetch('/api/plec/upload', {
}); method: 'POST',
const j = await res.json(); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
await readJSONStream(res, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message };
} else if (msg.type === 'rowErrors') {
j = { rowErrors: msg.rowErrors };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; return; }
if (j.rowErrors) { if (j.rowErrors) {
statusEl.classList.remove('is-busy');
for (const re of j.rowErrors) { for (const re of j.rowErrors) {
const row = rows.find(r => r.id === re.id); const row = rows.find(r => r.id === re.id);
if (row) row.error = re.message; if (row) row.error = re.message;
@@ -189,9 +237,11 @@ document.getElementById('upload').addEventListener('click', async () => {
return; return;
} }
if (j.error) { if (j.error) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + j.error + '</span>'; statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
return; return;
} }
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>'; statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
setPreview(j.preview); setPreview(j.preview);
}); });
@@ -247,11 +297,12 @@ type plecRow struct {
} }
func PlecUpload(w http.ResponseWriter, r *http.Request) { func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct { var req struct {
Rows []plecRow `json:"rows"` Rows []plecRow `json:"rows"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
cfg := LoadConfig().Plec cfg := LoadConfig().Plec
@@ -276,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} }
} }
if len(rowErrors) > 0 { if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors}) send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
return return
} }
if len(valid) == 0 { if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."}) send(map[string]any{"type": "error", "message": "Nothing to upload."})
return return
} }
@@ -290,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
mw := multipart.NewWriter(&buf) mw := multipart.NewWriter(&buf)
for i, row := range valid { for i, row := range valid {
idx := i + 1 idx := i + 1
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
data, err := os.ReadFile(row.Path) data, err := os.ReadFile(row.Path)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return return
} }
stampedName := appendTimestamp(filepath.Base(row.Path), stamp) stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html") fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
if _, err := fw.Write(data); err != nil { if _, err := fw.Write(data); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", "")) iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars. // Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", "")) iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil { if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
} }
if err := mw.Close(); err != nil { if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf) httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
httpReq.Header.Set("Content-Type", mw.FormDataContentType()) httpReq.Header.Set("Content-Type", mw.FormDataContentType())
@@ -332,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
resp, err := http.DefaultClient.Do(httpReq) resp, err := http.DefaultClient.Do(httpReq)
if err != nil { if err != nil {
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()}) send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body) respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))}) send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return return
} }
var parsed map[string]any var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil { if err := json.Unmarshal(respBody, &parsed); err != nil {
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)}) send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return return
} }
var preview any var preview any
@@ -352,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} else if v, ok := parsed["previewURL"]; ok { } else if v, ok := parsed["previewURL"]; ok {
preview = v preview = v
} }
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)}) send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
} }
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) { func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {

50
tools.json Normal file
View File

@@ -0,0 +1,50 @@
[
{
"id": "plec",
"title": "PLEC Upload",
"description": "Upload HTML to internal PLEC server",
"command": "hplToolbox.openPlecUpload",
"path": "/plec",
"beta": false
},
{
"id": "applovin",
"title": "AppLovin Playable Preview",
"description": "Upload to p.applov.in (QR preview)",
"command": "hplToolbox.openApplovinUpload",
"path": "/applovin",
"beta": false
},
{
"id": "base64",
"title": "Base64 Scanner",
"description": "Find non-base64 assets in HTML",
"command": "hplToolbox.openBase64Scanner",
"path": "/base64",
"beta": false
},
{
"id": "mobile",
"title": "Send To Mobile",
"description": "Share HTML to a phone via LAN + QR",
"command": "hplToolbox.openSendToMobile",
"path": "/mobile",
"beta": false
},
{
"id": "mraid",
"title": "MRAID Checker",
"description": "Check MRAID requirements and best practices",
"command": "hplToolbox.openMraidChecker",
"path": "/mraid",
"beta": false
},
{
"id": "playworks",
"title": "Playworks Converter",
"description": "Convert Playworks HTML to per-network variants",
"command": "hplToolbox.openPlayworksConverter",
"path": "/playworks",
"beta": true
}
]