diff --git a/dist/hpl-toolbox-0.1.6.exe b/dist/hpl-toolbox-0.1.7.exe similarity index 65% rename from dist/hpl-toolbox-0.1.6.exe rename to dist/hpl-toolbox-0.1.7.exe index dc85d6e..e504fe1 100644 Binary files a/dist/hpl-toolbox-0.1.6.exe and b/dist/hpl-toolbox-0.1.7.exe differ diff --git a/dist/hpl-toolbox-0.1.6.vsix b/dist/hpl-toolbox-0.1.7.vsix similarity index 99% rename from dist/hpl-toolbox-0.1.6.vsix rename to dist/hpl-toolbox-0.1.7.vsix index 37951c8..1c180ef 100644 Binary files a/dist/hpl-toolbox-0.1.6.vsix and b/dist/hpl-toolbox-0.1.7.vsix differ diff --git a/package.json b/package.json index 4002f5b..8e867ff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hpl-toolbox", "displayName": "HPL Toolbox", "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", "license": "UNLICENSED", "repository": { @@ -134,4 +134,4 @@ "playwright": "^1.60.0", "typescript": "^5.4.0" } -} +} \ No newline at end of file diff --git a/run-standalone.ps1 b/run-standalone.ps1 new file mode 100644 index 0000000..b8917ed --- /dev/null +++ b/run-standalone.ps1 @@ -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 +} diff --git a/src/launcherView.ts b/src/launcherView.ts index f92bb11..fd64ce3 100644 --- a/src/launcherView.ts +++ b/src/launcherView.ts @@ -1,15 +1,18 @@ import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; const BETA_TOOLS_ENABLED_KEY = 'hplToolbox.betaTools.enabled'; interface ToolDefinition { + id?: string; command: string; title: string; description: string; beta?: boolean; } -const TOOLS: ToolDefinition[] = [ +const FALLBACK_TOOLS: ToolDefinition[] = [ { command: 'hplToolbox.openPlecUpload', title: 'PLEC Upload', @@ -25,17 +28,16 @@ const TOOLS: ToolDefinition[] = [ title: 'Base64 Scanner', 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', title: 'Send To Mobile', 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', title: 'Playworks Converter', @@ -50,8 +52,9 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider { resolveWebviewView(view: vscode.WebviewView) { view.webview.options = { enableScripts: true }; const version = this.context.extension.packageJSON.version as string; + const tools = loadToolDefinitions(this.context.extensionPath); const betaToolsEnabled = this.context.globalState.get(BETA_TOOLS_ENABLED_KEY, false); - view.webview.html = getHtml(version, betaToolsEnabled); + view.webview.html = getHtml(version, betaToolsEnabled, tools); view.webview.onDidReceiveMessage(async (msg) => { if (msg?.type === 'open' && typeof msg.command === 'string') { vscode.commands.executeCommand(msg.command); @@ -59,14 +62,34 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider { vscode.commands.executeCommand('hplToolbox.openChangelog'); } else if (msg?.type === 'setBetaToolsEnabled' && typeof msg.enabled === 'boolean') { 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 { - const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta)); +function loadToolDefinitions(extensionPath: string): ToolDefinition[] { + 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'); return ` diff --git a/src/tools/applovinUpload.ts b/src/tools/applovinUpload.ts index 5b686ba..460e856 100644 --- a/src/tools/applovinUpload.ts +++ b/src/tools/applovinUpload.ts @@ -460,6 +460,7 @@ ${getToolWebviewStyles()} uploadBtn.addEventListener('click', () => { statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; outputPanel.classList.add('is-hidden'); if (!selectedPaths.length) { @@ -467,6 +468,8 @@ ${getToolWebviewStyles()} return; } uploadBtn.disabled = true; + statusEl.textContent = 'Uploading...'; + statusEl.classList.add('is-busy'); resultItems = []; saveAllBtn.disabled = true; regenAllBtn.disabled = true; @@ -504,8 +507,9 @@ ${getToolWebviewStyles()} regenAllBtn.disabled = true; } else if (m.type === 'status') { statusEl.textContent = m.message; - statusEl.className = 'status-panel'; + statusEl.className = 'status-panel is-busy'; } else if (m.type === 'error') { + statusEl.classList.remove('is-busy'); const div = document.createElement('div'); div.className = 'err'; div.textContent = m.message; @@ -558,6 +562,7 @@ ${getToolWebviewStyles()} saveAllBtn.disabled = resultItems.length === 0; regenAllBtn.disabled = resultItems.length === 0; } else if (m.type === 'done') { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'Done.'; uploadBtn.disabled = false; } diff --git a/src/tools/base64Scanner.ts b/src/tools/base64Scanner.ts index a50fe82..aedbd2b 100644 --- a/src/tools/base64Scanner.ts +++ b/src/tools/base64Scanner.ts @@ -279,11 +279,13 @@ ${getToolWebviewStyles()} selectedPage = 0; renderSelection(); statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; outputPanel.classList.add('is-hidden'); }); document.getElementById('scan').addEventListener('click', () => { statusEl.textContent = 'Scanning...'; + statusEl.classList.add('is-busy'); resultsEl.innerHTML = ''; outputPanel.classList.add('is-hidden'); if (pickedFiles.length) { @@ -312,11 +314,13 @@ ${getToolWebviewStyles()} renderSelection(); } else if (msg.type === 'results') { if (msg.error) { + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; outputPanel.classList.add('is-hidden'); return; } const total = msg.results.length; + statusEl.classList.remove('is-busy'); const flagged = msg.results.filter(r => !r.ok).length; statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.'; if (!total) { diff --git a/src/tools/mraidChecker.ts b/src/tools/mraidChecker.ts index 14b3a0b..a3e5fdb 100644 --- a/src/tools/mraidChecker.ts +++ b/src/tools/mraidChecker.ts @@ -796,11 +796,13 @@ ${getToolWebviewStyles()} selectedPage = 0; renderSelection(); statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; outputPanel.classList.add('is-hidden'); }); document.getElementById('scan').addEventListener('click', () => { statusEl.textContent = 'Scanning...'; + statusEl.classList.add('is-busy'); resultsEl.innerHTML = ''; outputPanel.classList.add('is-hidden'); if (pickedFiles.length) { @@ -829,11 +831,13 @@ ${getToolWebviewStyles()} renderSelection(); } else if (msg.type === 'results') { if (msg.error) { + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; outputPanel.classList.add('is-hidden'); return; } const total = msg.results.length; + statusEl.classList.remove('is-busy'); const withIssues = msg.results.filter(r => !r.ok).length; const skipped = Number(msg.skipped || 0); const skippedReason = msg.skippedReason || 'file(s).'; diff --git a/src/tools/playworksConverter.ts b/src/tools/playworksConverter.ts index a347dfd..b2bed4b 100644 --- a/src/tools/playworksConverter.ts +++ b/src/tools/playworksConverter.ts @@ -569,6 +569,7 @@ ${getToolWebviewStyles()} const networks = [...document.querySelectorAll('.net-cb')] .filter(c => c.checked).map(c => c.dataset.tag); statusEl.textContent = 'Converting...'; + statusEl.classList.add('is-busy'); resultsEl.innerHTML = ''; openOutBtn.style.display = 'none'; outputPanel.classList.add('is-hidden'); @@ -603,11 +604,13 @@ ${getToolWebviewStyles()} convertBtn.disabled = false; updateConvertBtn(); if (msg.error) { + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; outputPanel.classList.add('is-hidden'); return; } const results = msg.results || []; + statusEl.classList.remove('is-busy'); const ok = results.filter(r => r.ok).length; statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; if (!results.length) { diff --git a/src/tools/plecUpload.ts b/src/tools/plecUpload.ts index 8c45f7d..054c2d9 100644 --- a/src/tools/plecUpload.ts +++ b/src/tools/plecUpload.ts @@ -392,11 +392,14 @@ ${getToolWebviewStyles()} document.getElementById('upload').addEventListener('click', () => { clearErrors(); statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); setPreview(''); if (rows.length === 0) { statusEl.innerHTML = 'Select at least one file.'; return; } + statusEl.textContent = 'Uploading...'; + statusEl.classList.add('is-busy'); vscode.postMessage({ type: 'upload', rows }); }); openResultBtn.addEventListener('click', () => { @@ -417,19 +420,22 @@ ${getToolWebviewStyles()} addFiles(m.files || []); } else if (m.type === 'status') { statusEl.textContent = m.message; - statusEl.className = 'status-panel'; + statusEl.className = 'status-panel is-busy'; } else if (m.type === 'error') { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = ''; const div = document.createElement('div'); div.className = 'err'; div.textContent = m.message; statusEl.appendChild(div); } else if (m.type === 'rowError') { + statusEl.classList.remove('is-busy'); const row = rows.find(r => r.id === m.rowId); if (row) row.error = m.message; renderRows(); statusEl.innerHTML = 'Fix row errors and retry.'; } else if (m.type === 'result') { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'Upload complete.'; setPreview(m.preview); } diff --git a/src/tools/sendToMobile.ts b/src/tools/sendToMobile.ts index e92d8b1..cf089ae 100644 --- a/src/tools/sendToMobile.ts +++ b/src/tools/sendToMobile.ts @@ -421,6 +421,7 @@ ${getToolWebviewStyles()} selectedPage = 0; renderSelection(); statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); }); startBtn.addEventListener('click', () => { @@ -432,6 +433,7 @@ ${getToolWebviewStyles()} } startBtn.disabled = true; statusEl.textContent = 'Starting server...'; + statusEl.classList.add('is-busy'); vscode.postMessage({ type: 'startShare', paths: selectedPaths }); }); @@ -548,6 +550,7 @@ ${getToolWebviewStyles()} selectedPage = 0; renderSelection(); } else if (m.type === 'sharing') { + statusEl.classList.remove('is-busy'); currentUrls = m.urls; selectedIdx = 0; statusEl.innerHTML = 'Sharing: ' + m.filename + ''; @@ -558,6 +561,7 @@ ${getToolWebviewStyles()} startBtn.disabled = true; stopBtn.disabled = false; } else if (m.type === 'stopped') { + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Stopped.'; shareInfo.style.display = 'none'; ifaceWrap.style.display = 'none'; @@ -566,6 +570,7 @@ ${getToolWebviewStyles()} startBtn.disabled = false; stopBtn.disabled = true; } else if (m.type === 'error') { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = '' + (m.message || 'Error').replace(/'; startBtn.disabled = false; } diff --git a/src/tools/shared.ts b/src/tools/shared.ts index 9346869..acd895e 100644 --- a/src/tools/shared.ts +++ b/src/tools/shared.ts @@ -207,6 +207,24 @@ export function getToolWebviewStyles(): string { 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 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 { display: none; } diff --git a/standalone/applovin.go b/standalone/applovin.go index adb68f6..94a3063 100644 --- a/standalone/applovin.go +++ b/standalone/applovin.go @@ -44,10 +44,13 @@ func ApplovinPage(w http.ResponseWriter, r *http.Request) {

Inputs

-
- - - +
+
+ + + +
+
or drop HTML files or folders here
(no files selected)
@@ -154,6 +157,15 @@ regenAllBtn.addEventListener('click', () => { 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', () => { selectedPaths = []; @@ -184,12 +196,15 @@ pickBtn.addEventListener('click', async () => { uploadBtn.addEventListener('click', async () => { statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; if (!selectedPaths.length) { statusEl.innerHTML = 'Pick at least one HTML file first.'; return; } uploadBtn.disabled = true; + statusEl.textContent = 'Uploading...'; + statusEl.classList.add('is-busy'); resultItems = []; saveAllBtn.disabled = true; regenAllBtn.disabled = true; @@ -200,6 +215,7 @@ uploadBtn.addEventListener('click', async () => { body: JSON.stringify({ paths: selectedPaths }), }); if (!res.ok || !res.body) { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'Upload failed.'; uploadBtn.disabled = false; return; @@ -219,6 +235,7 @@ uploadBtn.addEventListener('click', async () => { } } } catch (e) { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = '' + (e.message || e) + ''; } finally { uploadBtn.disabled = false; @@ -240,6 +257,7 @@ qrModal.addEventListener('click', () => qrModal.classList.remove('open')); function handle(m) { if (m.type === 'status') { statusEl.textContent = m.message; + statusEl.classList.add('is-busy'); } else if (m.type === 'fileError') { const tbody = ensureResultsTable(); resultsEl.querySelector('table').classList.add('has-errors'); @@ -296,6 +314,7 @@ function handle(m) { saveAllBtn.disabled = resultItems.length === 0; regenAllBtn.disabled = resultItems.length === 0; } else if (m.type === 'done') { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = 'Done.'; } } diff --git a/standalone/base64.go b/standalone/base64.go index 465dee3..46c9a8a 100644 --- a/standalone/base64.go +++ b/standalone/base64.go @@ -21,10 +21,13 @@ func Base64Page(w http.ResponseWriter, r *http.Request) {

Inputs

-
- - - +
+
+ + + +
+
or drop HTML files or folders here
@@ -87,6 +90,10 @@ function addPaths(paths) { 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 () => { const r = await fetch('/api/base64/pickFolder', { method: 'POST' }); @@ -102,19 +109,22 @@ document.getElementById('clear').addEventListener('click', () => { pickedFiles = []; selectedPage = 0; statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; renderSelection(); }); document.getElementById('scan').addEventListener('click', async () => { statusEl.textContent = 'Scanning...'; + statusEl.classList.add('is-busy'); 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 r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const j = await r.json(); - if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } + if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; } const total = j.results.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.'; if (!total) { resultsEl.innerHTML = ''; return; } resultsEl.innerHTML = '
StatusFileIssuesNon-base64 assets
'; diff --git a/standalone/layout.go b/standalone/layout.go index c624569..a9743e4 100644 --- a/standalone/layout.go +++ b/standalone/layout.go @@ -2,8 +2,13 @@ package main import ( "encoding/json" + "fmt" "net/http" + "net/url" + "os" + "path/filepath" "strings" + "time" ) const SharedCSS = ` @@ -107,6 +112,9 @@ const SharedCSS = ` 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 { 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; } .results-panel { margin-top: var(--tool-gap-md); overflow: auto; } .results-panel:empty { display: none; } @@ -118,6 +126,28 @@ const SharedCSS = ` .mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; } .wrap { overflow-wrap: anywhere; word-break: break-word; } .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; } .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; } @@ -131,6 +161,160 @@ const SharedCSS = ` .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 { Path string Label string @@ -143,8 +327,8 @@ var navItems = []navItem{ {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: "/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: "/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}, } @@ -207,6 +391,7 @@ func Page(activePath, title, body string) string { ` + title + ` — HPL Toolbox +
` + tabs.String() + `
` + body + `
@@ -242,6 +427,153 @@ func boolAttr(v bool) string { 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) { var req struct { Enabled bool `json:"enabled"` diff --git a/standalone/main.go b/standalone/main.go index 25abd12..8d91656 100644 --- a/standalone/main.go +++ b/standalone/main.go @@ -121,6 +121,7 @@ func buildMux() *http.ServeMux { mux.HandleFunc("POST /api/open", OpenEndpoint) mux.HandleFunc("POST /api/focus", FocusEndpoint) mux.HandleFunc("POST /api/betaTools", BetaToolsEndpoint) + mux.HandleFunc("POST /api/drop/resolve", DropResolveEndpoint) // PLEC mux.HandleFunc("POST /api/plec/pick", PlecPick) diff --git a/standalone/mobile.go b/standalone/mobile.go index 4befe34..25c2edc 100644 --- a/standalone/mobile.go +++ b/standalone/mobile.go @@ -67,10 +67,13 @@ func MobilePage(w http.ResponseWriter, r *http.Request) {

Inputs

-
- - - +
+
+ + + +
+
or drop HTML files or folders here
(no files selected)
@@ -165,6 +168,10 @@ function addFiles(files) { 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 () => { const r = await fetch('/api/mobile/pickFolder', { method:'POST' }); @@ -181,6 +188,7 @@ clearBtn.addEventListener('click', () => { selectedPaths = []; selectedPage = 0; statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); renderSelection(); }); @@ -192,17 +200,20 @@ startBtn.addEventListener('click', async () => { } startBtn.disabled = true; statusEl.textContent = 'Starting server...'; + statusEl.classList.add('is-busy'); const r = await fetch('/api/mobile/start', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ paths: selectedPaths }), }); const j = await r.json(); if (j.error) { + statusEl.classList.remove('is-busy'); statusEl.innerHTML = '' + j.error + ''; startBtn.disabled = false; return; } currentUrls = j.urls; + statusEl.classList.remove('is-busy'); selectedIdx = 0; statusEl.innerHTML = 'Sharing: ' + j.filename + ''; shareInfo.style.display = ''; @@ -213,6 +224,7 @@ startBtn.addEventListener('click', async () => { stopBtn.addEventListener('click', async () => { await fetch('/api/mobile/stop', { method:'POST' }); + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Stopped.'; shareInfo.style.display = 'none'; currentUrls = []; diff --git a/standalone/mraid.go b/standalone/mraid.go index b7845c9..da26968 100644 --- a/standalone/mraid.go +++ b/standalone/mraid.go @@ -50,10 +50,13 @@ func MraidPage(w http.ResponseWriter, r *http.Request) {

Inputs

-
- - - +
+
+ + + +
+
or drop HTML files or folders here
@@ -129,6 +132,10 @@ function addPaths(paths) { 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 () => { const r = await fetch('/api/mraid/pickFolder', { method: 'POST' }); @@ -144,17 +151,19 @@ document.getElementById('clear').addEventListener('click', () => { pickedFiles = []; selectedPage = 0; statusEl.textContent = ''; + statusEl.classList.remove('is-busy'); resultsEl.innerHTML = ''; renderSelection(); }); document.getElementById('scan').addEventListener('click', async () => { statusEl.textContent = 'Scanning...'; + statusEl.classList.add('is-busy'); 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 r = await fetch('/api/mraid/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); const j = await r.json(); - if (j.error) { statusEl.textContent = 'Error: ' + j.error; return; } + if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; } const total = j.results.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); @@ -162,6 +171,7 @@ document.getElementById('scan').addEventListener('click', async () => { const skipped = Number(j.skipped || 0); const skippedReason = j.skippedReason || 'file(s).'; const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : ''; + statusEl.classList.remove('is-busy'); statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText; if (!total) { resultsEl.innerHTML = ''; return; } resultsEl.innerHTML = '
FileResults
'; diff --git a/standalone/playworks.go b/standalone/playworks.go index 69d33c4..48fa054 100644 --- a/standalone/playworks.go +++ b/standalone/playworks.go @@ -47,10 +47,15 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
Source HTML (Playworks export)
-
- - +
+
+ + +
+
or drop a Playworks HTML file here
+ +
@@ -106,22 +111,59 @@ const convertBtn = document.getElementById('convert'); const openOutBtn = document.getElementById('openOut'); const statusEl = document.getElementById('status'); const resultsEl = document.getElementById('results'); +const sourceSelectionEl = document.getElementById('sourceSelection'); function updateConvertBtn() { const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked); 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +function renderSourceSelection() { + if (!srcEl.value) { + sourceSelectionEl.innerHTML = '(no file selected)'; + return; + } + sourceSelectionEl.innerHTML = '
' + + '' + + '' + + '
Filename
' + escapeHtml(basename(srcEl.value)) + '
'; + 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 () => { const r = await fetch('/api/playworks/pickSource', { method:'POST' }); const j = await r.json(); if (j.path) { - srcEl.value = j.path; - baseNameEl.value = j.baseName || ''; - if (!outEl.value && j.dir) { outEl.value = j.dir; outputDir = j.dir; } - updateConvertBtn(); + setSource(j.path, j.baseName || '', j.dir || ''); } }); +document.getElementById('clearSrc').addEventListener('click', clearSource); document.getElementById('pickOut').addEventListener('click', async () => { const r = await fetch('/api/playworks/pickOutput', { method:'POST' }); const j = await r.json(); @@ -146,10 +188,25 @@ function outputDisplayName(file) { const parts = file.split(/[\\\\/]/).filter(Boolean); 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 () => { const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag); statusEl.textContent = 'Converting...'; + statusEl.classList.add('is-busy'); resultsEl.innerHTML = ''; openOutBtn.disabled = true; convertBtn.disabled = true; @@ -161,9 +218,10 @@ document.getElementById('convert').addEventListener('click', async () => { const msg = await res.json(); convertBtn.disabled = false; updateConvertBtn(); - if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; } + if (msg.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + msg.error; return; } const results = msg.results || []; const ok = results.filter(r => r.ok).length; + statusEl.classList.remove('is-busy'); statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.'; if (!results.length) { resultsEl.innerHTML = ''; return; } resultsEl.innerHTML = '
NetworkStatusOutput PathError
'; diff --git a/standalone/plec.go b/standalone/plec.go index 972ec88..d6c6c04 100644 --- a/standalone/plec.go +++ b/standalone/plec.go @@ -26,10 +26,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {

Inputs

-
- - - +
+
+ + + +
+
or drop HTML files or folders here
(no files selected)