Compare commits
7 Commits
playworks-
...
afbf504957
| Author | SHA1 | Date | |
|---|---|---|---|
| afbf504957 | |||
| 16847a78ca | |||
| ca9cc62b8c | |||
| 8e8d3d6da0 | |||
| 3f86aeaf0c | |||
| 92de00b52e | |||
| 78578cec16 |
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
# CHANGELOG
|
||||
## v0.1.2:
|
||||
- Added local hosting for html files
|
||||
- Created standalone version
|
||||
|
||||
## v0.1.3
|
||||
- Updated Applovin Demo Upload
|
||||
- Changed file selection workflow
|
||||
- Added "Regenerate QRs" if in case they don't render
|
||||
- Added "Save QR" and "Save All QRs"
|
||||
|
||||
## v0.1.5
|
||||
- Better file selection for PLEC Uoload
|
||||
- Multi file selection
|
||||
- Auto detects iteration name (it tries its best)
|
||||
|
||||
Standalone .exe
|
||||
- Move temp files into user temp folder
|
||||
|
||||
## v0.1.6
|
||||
- Simplify UI
|
||||
- Added beta tools (needs further testing)
|
||||
- Playworks Converter
|
||||
- Upload UnityAds HTML from Playworks to add injections for the other networks
|
||||
- MRAID Checker
|
||||
- Based on Official MRAID Document, scans the HTML files if they adhere to the documentation requirements and best practices
|
||||
19
aiAssets/LAYOUT.md
Normal file
19
aiAssets/LAYOUT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Unified Layout
|
||||
|
||||
[Select Folder] [Select Files] [Clear]
|
||||
|
||||
(PLEC UPLOAD)
|
||||
| FILENAME |ITERATION NAME| |
|
||||
|-----------------|--------------|---|
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|pato-to-file.html| 01_full | X |
|
||||
|
||||
(AppLovin Playable Preview)(Base64 Scanner)(Send To Mobile)(MRAID Checker)
|
||||
| FILENAME | |
|
||||
|-----------------|-------|
|
||||
|pato-to-file.html| X |
|
||||
|pato-to-file.html| X |
|
||||
|pato-to-file.html| X |
|
||||
|
||||
[Scan]/[Upload]/[Start]
|
||||
Binary file not shown.
Binary file not shown.
15
package.json
15
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hpl-toolbox",
|
||||
"displayName": "HPL Toolbox",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Demo Upload, Base64 Asset Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.5",
|
||||
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
|
||||
"version": "0.1.6",
|
||||
"publisher": "hesukastro",
|
||||
"license": "UNLICENSED",
|
||||
"repository": {
|
||||
@@ -12,7 +12,9 @@
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": ["Other"],
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"icon": "media/hpl.png",
|
||||
"main": "./out/extension.js",
|
||||
"activationEvents": [],
|
||||
@@ -24,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openApplovinUpload",
|
||||
"title": "HPL Toolbox: Open AppLovin Demo Upload"
|
||||
"title": "HPL Toolbox: Open AppLovin Playable Preview"
|
||||
},
|
||||
{
|
||||
"command": "hplToolbox.openBase64Scanner",
|
||||
@@ -57,7 +59,7 @@
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "hplToolbox.launcher",
|
||||
"name": "Tools"
|
||||
"name": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -121,7 +123,8 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "tsc -p ./",
|
||||
"sync-readme": "node scripts/sync-readme.js",
|
||||
"compile": "npm run sync-readme && tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"vscode:prepublish": "npm run compile"
|
||||
},
|
||||
|
||||
20
scripts/sync-readme.js
Normal file
20
scripts/sync-readme.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const changelogPath = path.join(root, 'CHANGELOG.md');
|
||||
const readmePath = path.join(root, 'README.md');
|
||||
|
||||
let changelog = '# CHANGELOG\n\nNo changelog entries yet.';
|
||||
if (fs.existsSync(changelogPath)) {
|
||||
changelog = fs.readFileSync(changelogPath, 'utf8').trim();
|
||||
}
|
||||
|
||||
const readme = `# HPL Toolbox
|
||||
|
||||
Bundled VS Code extension and standalone tools for playable ad workflows.
|
||||
|
||||
${changelog}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(readmePath, readme.replace(/\r?\n/g, '\n'), 'utf8');
|
||||
122
src/changelogView.ts
Normal file
122
src/changelogView.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export function openChangelog(context: vscode.ExtensionContext) {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
'hplToolbox.changelog',
|
||||
'HPL Toolbox Changelog',
|
||||
vscode.ViewColumn.One,
|
||||
{ enableScripts: false }
|
||||
);
|
||||
const changelogPath = path.join(context.extensionPath, 'CHANGELOG.md');
|
||||
let markdown = '# Changelog\n\nCHANGELOG.md was not found.';
|
||||
try {
|
||||
markdown = fs.readFileSync(changelogPath, 'utf8');
|
||||
} catch {
|
||||
// Keep the fallback text readable in packaged or development installs.
|
||||
}
|
||||
panel.webview.html = getHtml(renderMarkdown(markdown));
|
||||
}
|
||||
|
||||
function getHtml(content: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
main {
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h2 {
|
||||
margin: 18px 0 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
ul {
|
||||
margin: 6px 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
li {
|
||||
margin: 4px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>${content}</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>');
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = Math.min(heading[1].length, 2);
|
||||
html.push(`<h${level}>${escapeHtml(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const listItem = line.match(/^-\s+(.+)$/);
|
||||
if (listItem) {
|
||||
if (!inList) {
|
||||
html.push('<ul>');
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${escapeHtml(listItem[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${escapeHtml(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, ch => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch] ?? ch));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { openBase64Scanner } from './tools/base64Scanner';
|
||||
import { openMraidChecker } from './tools/mraidChecker';
|
||||
import { openSendToMobile } from './tools/sendToMobile';
|
||||
import { openPlayworksConverter } from './tools/playworksConverter';
|
||||
import { openChangelog } from './changelogView';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
@@ -15,6 +16,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand('hplToolbox.openMraidChecker', () => openMraidChecker(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openSendToMobile', () => openSendToMobile(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openPlayworksConverter', () => openPlayworksConverter(context)),
|
||||
vscode.commands.registerCommand('hplToolbox.openChangelog', () => openChangelog(context)),
|
||||
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider(context))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
command: 'hplToolbox.openApplovinUpload',
|
||||
title: 'AppLovin Demo Upload',
|
||||
title: 'AppLovin Playable Preview',
|
||||
description: 'Upload to p.applov.in (QR preview)',
|
||||
},
|
||||
{
|
||||
@@ -55,6 +55,8 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
view.webview.onDidReceiveMessage(async (msg) => {
|
||||
if (msg?.type === 'open' && typeof msg.command === 'string') {
|
||||
vscode.commands.executeCommand(msg.command);
|
||||
} else if (msg?.type === 'openChangelog') {
|
||||
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);
|
||||
@@ -65,37 +67,73 @@ export class LauncherViewProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
const visibleTools = sortToolsForDisplay(TOOLS.filter(tool => betaToolsEnabled || !tool.beta));
|
||||
const hiddenBetaCount = TOOLS.filter(tool => tool.beta).length - visibleTools.filter(tool => tool.beta).length;
|
||||
const toolButtons = visibleTools.map(renderToolButton).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
min-height: calc(100vh - 24px);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 10px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
|
||||
color: var(--vscode-foreground);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
|
||||
.tools { flex: 1; }
|
||||
.tools {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.tool-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px; cursor: pointer; font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-button-secondaryBackground));
|
||||
color: var(--vscode-foreground);
|
||||
border: 1px solid var(--vscode-panel-border, transparent);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.tool-btn:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
background: var(--vscode-list-hoverBackground, var(--vscode-button-secondaryHoverBackground));
|
||||
border-color: var(--vscode-focusBorder, var(--vscode-panel-border));
|
||||
}
|
||||
.tool-btn:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.tool-btn:active {
|
||||
background: var(--vscode-list-activeSelectionBackground, var(--vscode-button-background));
|
||||
color: var(--vscode-list-activeSelectionForeground, var(--vscode-button-foreground));
|
||||
}
|
||||
.tool-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.tool-desc {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.tool-title { display: flex; align-items: center; gap: 6px; }
|
||||
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||
.beta-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
@@ -103,37 +141,54 @@ function getHtml(version: string, betaToolsEnabled: boolean): string {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.beta-toggle {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
.beta-toggle button {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
.footer-action {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.beta-toggle button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
#betaToggle {
|
||||
justify-self: start;
|
||||
}
|
||||
.footer-action:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer-version {
|
||||
opacity: 0.75;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.changelog-link {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
.beta-note { display: block; margin-top: 3px; color: var(--vscode-descriptionForeground); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>HPL Toolbox ${version}</h3>
|
||||
<div class="tools">
|
||||
${toolButtons}
|
||||
</div>
|
||||
<div class="beta-toggle">
|
||||
<button id="betaToggle" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">
|
||||
${betaToolsEnabled ? 'Disable Beta Tools' : 'Enable Beta Tools'}
|
||||
<span class="beta-note">${betaToolsEnabled ? 'Beta tools are visible in this launcher.' : `${hiddenBetaCount} beta tool(s) hidden until enabled.`}</span>
|
||||
</button>
|
||||
<div class="footer">
|
||||
<button id="betaToggle" class="footer-action" data-enabled="${betaToolsEnabled ? 'true' : 'false'}">${betaToolsEnabled ? 'Hide beta' : 'Show beta'}</button>
|
||||
<span class="footer-version">${version} - JJGC 00784</span>
|
||||
<button id="changelogLink" class="footer-action changelog-link">Changelog</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
@@ -146,6 +201,9 @@ ${toolButtons}
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
vscode.postMessage({ type: 'setBetaToolsEnabled', enabled: !enabled });
|
||||
});
|
||||
document.getElementById('changelogLink').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'openChangelog' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -2,12 +2,12 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload');
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Playable Preview');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -15,6 +15,24 @@ export function openApplovinUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'filesSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: true,
|
||||
@@ -175,6 +193,30 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
return vscode.Uri.file(root);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function qrBaseName(name: string): string {
|
||||
return (name || 'qr').replace(/\.html?$/i, '').replace(/[\\/:*?"<>|]/g, '_') || 'qr';
|
||||
}
|
||||
@@ -243,54 +285,87 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
${getToolWebviewStyles()}
|
||||
.selected-files { margin-top: 10px; }
|
||||
.qr-cell { text-align: center; }
|
||||
.qr-cell img {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
max-width: 100%;
|
||||
padding: 6px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
.result-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qr-cell img { cursor: zoom-in; }
|
||||
.qr-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: 10;
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.qr-modal.open { display: flex; }
|
||||
.qr-modal img {
|
||||
width: min(70vw, 520px);
|
||||
height: min(70vw, 520px);
|
||||
padding: 18px;
|
||||
border-radius: var(--tool-radius);
|
||||
background: #fff;
|
||||
}
|
||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
#results { margin-top: 12px; display: grid; grid-template-columns: repeat(auto-fill,minmax(280px,1fr)); gap: 12px; }
|
||||
.qr-results:not(.has-errors) .error-col { display: none; }
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app (iOS/Android).</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
</header>
|
||||
|
||||
<div class="file-cell" style="margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span class="file-name" id="fileName">(no files)</span>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-files"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div class="actions">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="qrModal" class="qr-modal" title="Click to close"><img alt="Expanded QR" /></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
@@ -298,9 +373,13 @@ function getHtml(): string {
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
let resultItems = [];
|
||||
|
||||
function basename(p) {
|
||||
@@ -308,15 +387,51 @@ function getHtml(): string {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.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>' +
|
||||
visiblePaths.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return (
|
||||
'<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||
);
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
selectedPaths.splice(Number(btn.dataset.index), 1);
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (selectedPaths.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', () => {
|
||||
@@ -331,24 +446,30 @@ function getHtml(): string {
|
||||
img.src = base + '&_=' + Date.now();
|
||||
});
|
||||
});
|
||||
qrModal.addEventListener('click', () => {
|
||||
qrModal.classList.remove('open');
|
||||
});
|
||||
|
||||
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', () => {
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
vscode.postMessage({ type: 'upload', paths: selectedPaths });
|
||||
});
|
||||
|
||||
@@ -356,20 +477,34 @@ function getHtml(): string {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table qr-results">' +
|
||||
'<thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const added = m.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();
|
||||
} else if (m.type === 'start') {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel';
|
||||
} else if (m.type === 'error') {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'err';
|
||||
@@ -378,20 +513,32 @@ function getHtml(): string {
|
||||
statusEl.appendChild(div);
|
||||
uploadBtn.disabled = false;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td class="mono wrap">' + escapeHtml(m.name) + '</td>' +
|
||||
'<td class="muted">-</td>' +
|
||||
'<td class="muted">-</td>' +
|
||||
'<td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;word-break:break-all;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;word-break:break-all;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||
qr.querySelector('img').addEventListener('click', (event) => {
|
||||
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||
qrModal.classList.add('open');
|
||||
});
|
||||
const actionCell = document.createElement('td');
|
||||
const error = document.createElement('td');
|
||||
error.className = 'error-col';
|
||||
error.innerHTML = '<span class="muted">-</span>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const openPreview = document.createElement('button');
|
||||
@@ -404,13 +551,12 @@ function getHtml(): string {
|
||||
saveQr.onclick = () => vscode.postMessage({ type: 'saveQr', url: m.qrImg, name: m.name });
|
||||
actions.appendChild(openPreview);
|
||||
actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
uploadBtn.disabled = false;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Asset Scanner');
|
||||
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
|
||||
if (!isNew) return;
|
||||
|
||||
panel.webview.html = getHtml();
|
||||
@@ -16,7 +16,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
const files = await collectHtmlFiles(distPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
@@ -27,7 +28,8 @@ export function openBase64Scanner(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -144,46 +146,125 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.row-result { display: flex; gap: 8px; padding: 2px 0; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 14px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.assets { color: var(--vscode-descriptionForeground); margin-left: 6px; word-break: break-all; }
|
||||
.summary { margin-top: 12px; opacity: 0.8; }
|
||||
${getToolWebviewStyles()}
|
||||
.asset-list { margin: 0; padding-left: 16px; }
|
||||
.asset-list li + li { margin-top: 3px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const items = pickedFiles.length
|
||||
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||
if (!items.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
selectionEl.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>' +
|
||||
visibleItems.map(item =>
|
||||
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||
).join('') +
|
||||
'</tbody></table>' +
|
||||
'</div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.type === 'folder') {
|
||||
pickedFolder = '';
|
||||
folderEl.value = '';
|
||||
} else {
|
||||
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||
}
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -191,9 +272,20 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
folderEl.value = '';
|
||||
pickedFiles = [];
|
||||
pickedFolder = '';
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -207,35 +299,63 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
pickedFolder = '';
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const total = msg.results.length;
|
||||
const flagged = msg.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of msg.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = r.file;
|
||||
row.appendChild(mark);
|
||||
row.appendChild(name);
|
||||
if (!r.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + r.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
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>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const row = document.createElement('tr');
|
||||
const status = document.createElement('td');
|
||||
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
|
||||
const name = document.createElement('td');
|
||||
name.className = 'mono wrap';
|
||||
name.textContent = r.file;
|
||||
const count = document.createElement('td');
|
||||
count.textContent = String(r.assets.length);
|
||||
const assets = document.createElement('td');
|
||||
assets.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'asset-list mono';
|
||||
r.assets.forEach(asset => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = asset;
|
||||
list.appendChild(item);
|
||||
});
|
||||
assets.appendChild(list);
|
||||
} else {
|
||||
assets.innerHTML = '<span class="muted">None</span>';
|
||||
}
|
||||
row.append(status, name, count, assets);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -50,7 +50,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
if (ws) {
|
||||
const distPath = path.join(ws.uri.fsPath, 'dist');
|
||||
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: distPath });
|
||||
const files = (await collectHtmlFiles(distPath)).filter(isInSupportedMraidFolderPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'pickFolder') {
|
||||
@@ -61,7 +62,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
openLabel: 'Select Folder',
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
|
||||
const files = (await collectHtmlFiles(picked[0].fsPath)).filter(isInSupportedMraidFolderPath);
|
||||
panel.webview.postMessage({ type: 'filesPicked', paths: files });
|
||||
}
|
||||
} else if (msg.type === 'pickFiles') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
@@ -80,15 +82,21 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
let targets: string[] = [];
|
||||
let baseDir = '';
|
||||
let skipped = 0;
|
||||
let skippedReason = '';
|
||||
let noTargetsError = 'No HTML files were found.';
|
||||
if (files && files.length) {
|
||||
const existing = files.filter(f => fs.existsSync(f));
|
||||
targets = existing.filter(isSupportedMraidBuild);
|
||||
skipped = existing.length - targets.length;
|
||||
targets = existing;
|
||||
skipped = files.length - existing.length;
|
||||
skippedReason = 'missing file(s).';
|
||||
noTargetsError = 'No selected HTML files were found.';
|
||||
baseDir = targets.length ? commonBaseDir(targets) : '';
|
||||
} else if (folder && fs.existsSync(folder)) {
|
||||
const allHtmlFiles = await collectHtmlFiles(folder);
|
||||
targets = allHtmlFiles.filter(isSupportedMraidBuild);
|
||||
targets = allHtmlFiles.filter(isInSupportedMraidFolderPath);
|
||||
skipped = allHtmlFiles.length - targets.length;
|
||||
skippedReason = 'HTML file(s) outside AppLovin/ironSource/Unity folders.';
|
||||
noTargetsError = 'No AppLovin, ironSource, or Unity folder HTML files were found.';
|
||||
baseDir = folder;
|
||||
} else {
|
||||
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
|
||||
@@ -100,7 +108,8 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
type: 'results',
|
||||
results: [],
|
||||
skipped,
|
||||
error: 'No AppLovin, ironSource, or Unity HTML builds were found.',
|
||||
skippedReason,
|
||||
error: noTargetsError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -116,7 +125,7 @@ export function openMraidChecker(_context: vscode.ExtensionContext) {
|
||||
// Skip unreadable files, matching the scanner pattern used elsewhere.
|
||||
}
|
||||
}
|
||||
panel.webview.postMessage({ type: 'results', results, skipped });
|
||||
panel.webview.postMessage({ type: 'results', results, skipped, skippedReason });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -193,9 +202,24 @@ function collectMraidCallLines(html: string): Map<string, number> {
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isSupportedMraidBuild(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase().replace(/\\/g, '/');
|
||||
return /(^|[\/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([\/._ -]|$)/i.test(normalized);
|
||||
function isInSupportedMraidFolderPath(filePath: string): boolean {
|
||||
const folderParts = path.dirname(filePath)
|
||||
.split(/[\\/]+/)
|
||||
.map(part => part.toLowerCase());
|
||||
return folderParts.some(part => [
|
||||
'al',
|
||||
'applovin',
|
||||
'app-lovin',
|
||||
'is',
|
||||
'ironsource',
|
||||
'iron-source',
|
||||
'iron_source',
|
||||
'un',
|
||||
'unity',
|
||||
'unityads',
|
||||
'unity-ads',
|
||||
'unity_ads',
|
||||
].includes(part));
|
||||
}
|
||||
|
||||
function hasMraidReference(ctx: ScanContext): boolean {
|
||||
@@ -362,53 +386,76 @@ function getHtml(): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); padding: 12px; color: var(--vscode-foreground); }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
input[type=text] { flex: 1; padding: 4px 6px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); }
|
||||
button { padding: 4px 12px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; cursor: pointer; }
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.summary { margin-top: 12px; opacity: 0.85; }
|
||||
.docs { color: var(--vscode-descriptionForeground); margin-bottom: 12px; }
|
||||
.row-result { padding: 8px 0; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.file-head { display: flex; gap: 8px; align-items: baseline; font-family: var(--vscode-editor-font-family); font-size: 12px; }
|
||||
.mark { width: 18px; flex-shrink: 0; font-weight: bold; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.file-name { word-break: break-all; }
|
||||
.counts { color: var(--vscode-descriptionForeground); margin-left: auto; white-space: nowrap; }
|
||||
.issues { margin: 6px 0 0 26px; padding: 0; }
|
||||
.issue { margin: 6px 0; list-style: none; }
|
||||
.badge { display: inline-block; min-width: 82px; margin-right: 6px; padding: 1px 5px; border-radius: 3px; font-size: 11px; text-align: center; }
|
||||
.requirement { color: #ffb4ad; background: rgba(248,81,73,0.18); }
|
||||
.best-practice { color: #ffd580; background: rgba(210,153,34,0.18); }
|
||||
${getToolWebviewStyles()}
|
||||
.issues { margin: 0; padding: 0; }
|
||||
.issue { list-style: none; margin: 0 0 8px; }
|
||||
.issue:last-child { margin-bottom: 0; }
|
||||
.severity-badge {
|
||||
display: inline-flex;
|
||||
min-width: 82px;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tool-border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.requirement { color: var(--vscode-errorForeground); background: var(--vscode-inputValidation-errorBackground, rgba(248,81,73,0.14)); }
|
||||
.best-practice { color: var(--vscode-editorWarning-foreground, #d29922); background: var(--vscode-inputValidation-warningBackground, rgba(210,153,34,0.14)); }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-detail { color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.selected-list { margin-top: 8px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MRAID Checker</h2>
|
||||
<div class="docs">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</div>
|
||||
<div class="row">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." />
|
||||
<button id="pick">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="field-row">
|
||||
<input id="folder" type="hidden" />
|
||||
<button id="pick" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="summary"></div>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFolder = '';
|
||||
let pickedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function clearFiles() {
|
||||
pickedFiles = [];
|
||||
selectionEl.textContent = '';
|
||||
function basename(p) {
|
||||
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
@@ -421,7 +468,57 @@ function getHtml(): string {
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const items = pickedFiles.length
|
||||
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
|
||||
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
|
||||
if (!items.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
selectionEl.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>' +
|
||||
visibleItems.map(item =>
|
||||
'<tr><td class="mono wrap">' + escapeText(item.label) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
|
||||
).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.type === 'folder') {
|
||||
pickedFolder = '';
|
||||
folderEl.value = '';
|
||||
} else {
|
||||
pickedFiles.splice(Number(btn.dataset.index), 1);
|
||||
}
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (items.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
@@ -429,9 +526,20 @@ function getHtml(): string {
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFiles' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
folderEl.value = '';
|
||||
pickedFolder = '';
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (pickedFiles.length) {
|
||||
vscode.postMessage({ type: 'scan', files: pickedFiles });
|
||||
} else {
|
||||
@@ -445,32 +553,48 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'folderPicked') {
|
||||
folderEl.value = msg.path;
|
||||
clearFiles();
|
||||
pickedFolder = msg.path;
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (msg.type === 'filesPicked') {
|
||||
pickedFiles = msg.paths;
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
pickedFolder = '';
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
} else if (msg.type === 'results') {
|
||||
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; return; }
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const total = msg.results.length;
|
||||
const withIssues = msg.results.filter(r => !r.ok).length;
|
||||
const requirements = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = msg.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(msg.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
const skippedReason = msg.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) {
|
||||
resultsEl.innerHTML = '';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th>File</th><th>Results</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of msg.results) {
|
||||
const requirementsForFile = r.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bestPracticesForFile = r.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML =
|
||||
'<div class="file-head">' +
|
||||
'<span class="mark ' + (r.ok ? 'ok' : 'bad') + '">' + (r.ok ? 'OK' : 'X') + '</span>' +
|
||||
'<span class="file-name">' + escapeText(r.file) + '</span>' +
|
||||
'<span class="counts">' + requirementsForFile + ' req / ' + bestPracticesForFile + ' bp</span>' +
|
||||
'</div>';
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = r.file;
|
||||
const issues = document.createElement('td');
|
||||
issues.className = 'wrap';
|
||||
if (!r.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
@@ -479,14 +603,17 @@ function getHtml(): string {
|
||||
item.className = 'issue';
|
||||
const line = i.line ? ' line ' + i.line + ':' : '';
|
||||
item.innerHTML =
|
||||
'<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="severity-badge ' + i.severity + '">' + escapeText(i.severity) + '</span>' +
|
||||
'<span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span>' +
|
||||
'<div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
issues.appendChild(list);
|
||||
} else {
|
||||
issues.innerHTML = '<span class="badge ok">OK</span>';
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
row.append(file, issues);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,13 +2,14 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cp from 'child_process';
|
||||
import { singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
interface ConvertOptions {
|
||||
sourcePath: string;
|
||||
outputDir: string;
|
||||
baseName?: string;
|
||||
networks: string[];
|
||||
}
|
||||
|
||||
@@ -38,7 +39,7 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
if (!picked?.[0]) return;
|
||||
const srcPath = picked[0].fsPath;
|
||||
const dir = path.dirname(srcPath);
|
||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir });
|
||||
panel.webview.postMessage({ type: 'sourcePicked', path: srcPath, dir, baseName: sourceBaseName(srcPath) });
|
||||
break;
|
||||
}
|
||||
case 'pickOutput': {
|
||||
@@ -94,21 +95,41 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']);
|
||||
const NETWORK_OUTPUT_FOLDERS: Record<string, string> = {
|
||||
al: 'Applovin',
|
||||
is: 'Ironsource',
|
||||
un: 'Unity',
|
||||
fb: 'Facebook',
|
||||
gg: 'GoogleAds',
|
||||
mo: 'Moloco',
|
||||
vu: 'Vungle',
|
||||
mtg: 'Mintegral',
|
||||
tt: 'TikTok',
|
||||
};
|
||||
|
||||
function sourceBaseName(sourcePath: string): string {
|
||||
return path.basename(sourcePath, path.extname(sourcePath))
|
||||
.replace(/_(?:un|al|is|fb|gg|mo|vu|mtg|tt)$/, '');
|
||||
}
|
||||
|
||||
function outputBaseName(opts: ConvertOptions): string {
|
||||
const rawBaseName = opts.baseName?.trim() || sourceBaseName(opts.sourcePath);
|
||||
const withoutExtension = rawBaseName.replace(/\.html?$/i, '');
|
||||
const safeBaseName = path.basename(withoutExtension).replace(/[<>:"/\\|?*]/g, '_').trim();
|
||||
return safeBaseName || sourceBaseName(opts.sourcePath);
|
||||
}
|
||||
|
||||
async function convertNetwork(html: string, opts: ConvertOptions, network: string): Promise<string> {
|
||||
const transformed = transformHtml(html, network);
|
||||
const needsZip = ZIPPED_NETWORKS.has(network);
|
||||
const baseName = sourceBaseName(opts.sourcePath);
|
||||
const baseName = outputBaseName(opts);
|
||||
const htmlFileName = `${baseName}_${network}.html`;
|
||||
const outputDir = path.join(opts.outputDir, NETWORK_OUTPUT_FOLDERS[network] ?? network);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (needsZip) {
|
||||
const tmpPath = path.join(opts.outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(opts.outputDir, `${baseName}_${network}.zip`);
|
||||
const tmpPath = path.join(outputDir, `_tmp_${Date.now()}_${network}.html`);
|
||||
const zipPath = path.join(outputDir, `${baseName}_${network}.zip`);
|
||||
fs.writeFileSync(tmpPath, transformed, 'utf8');
|
||||
try {
|
||||
await createZip(tmpPath, 'index.html', zipPath);
|
||||
@@ -118,7 +139,7 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
const outPath = path.join(opts.outputDir, htmlFileName);
|
||||
const outPath = path.join(outputDir, htmlFileName);
|
||||
fs.writeFileSync(outPath, transformed, 'utf8');
|
||||
return outPath;
|
||||
}
|
||||
@@ -303,121 +324,108 @@ function getHtml(): string {
|
||||
];
|
||||
|
||||
const checkboxRows = networks.map(n => `
|
||||
<label class="net-row">
|
||||
<input type="checkbox" class="net-cb" data-tag="${n.tag}" checked />
|
||||
<span class="net-label">${n.label} <span class="net-tag">${n.tag}</span></span>
|
||||
<span class="net-note">${n.note}</span>
|
||||
</label>`).join('');
|
||||
<tr>
|
||||
<td style="width:70px;"><input type="checkbox" class="net-cb" data-tag="${n.tag}"${n.tag === 'tt' ? '' : ' checked'} /></td>
|
||||
<td style="width:160px;"><span class="net-label">${n.label}</span> <span class="net-tag">${n.tag}</span></td>
|
||||
<td><span class="net-note">${n.note}</span></td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 12px 10px; font-size: 13px; }
|
||||
h2 { font-size: 14px; margin: 0 0 14px 0; font-weight: 600; }
|
||||
.section { margin-bottom: 14px; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.65; margin-bottom: 5px; }
|
||||
.row { display: flex; gap: 6px; align-items: center; }
|
||||
input[type=text] {
|
||||
flex: 1; padding: 4px 7px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, #555);
|
||||
border-radius: 2px; font-size: 12px;
|
||||
}
|
||||
button {
|
||||
padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 2px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: 1px solid var(--vscode-panel-border, #444);
|
||||
}
|
||||
button:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
button.primary {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 18px; font-size: 13px;
|
||||
}
|
||||
button.primary:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
.net-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.net-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; }
|
||||
${getToolWebviewStyles()}
|
||||
.net-label { min-width: 100px; font-weight: 500; }
|
||||
.net-tag { font-size: 10px; opacity: 0.6; font-weight: 400; }
|
||||
.net-note { font-size: 11px; opacity: 0.6; }
|
||||
.toggle-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
.toggle-row button { font-size: 11px; padding: 2px 8px; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; }
|
||||
#status { margin-top: 12px; font-size: 12px; opacity: 0.75; min-height: 16px; }
|
||||
.results { margin-top: 10px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.res-row { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: var(--vscode-editor-font-family); }
|
||||
.mark { font-weight: bold; width: 14px; flex-shrink: 0; }
|
||||
.ok { color: #3fb950; }
|
||||
.bad { color: #f85149; }
|
||||
.res-net { min-width: 70px; font-weight: 500; }
|
||||
.res-file { opacity: 0.65; word-break: break-all; }
|
||||
.res-err { color: #f85149; opacity: 0.9; }
|
||||
hr { border: none; border-top: 1px solid var(--vscode-panel-border, #444); margin: 14px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Playworks Converter</h2>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Playworks Converter</h2>
|
||||
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div class="field-row">
|
||||
<input id="src" type="hidden" />
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
<span id="srcName" class="file-name">(no file)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." />
|
||||
<button id="pickOut">Browse...</button>
|
||||
<button id="pickOut" class="secondary">Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to Generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
<button id="selectAll" class="secondary">Select All</button>
|
||||
<button id="selectNone" class="secondary">Select None</button>
|
||||
</div>
|
||||
<div class="net-grid">${checkboxRows}</div>
|
||||
</div>
|
||||
<div class="results-panel" style="margin-top:0;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th style="width:70px;">Use</th><th style="width:160px;">Network</th><th>Output behavior</th></tr></thead>
|
||||
<tbody>${checkboxRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" id="convert" disabled>Convert</button>
|
||||
<button id="openOut" style="display:none">Open Output Folder</button>
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="results" class="results-panel"></div>
|
||||
<div class="action-row">
|
||||
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
let outputDir = '';
|
||||
|
||||
const srcEl = document.getElementById('src');
|
||||
const srcNameEl = document.getElementById('srcName');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', () => vscode.postMessage({ type: 'pickSource' }));
|
||||
document.getElementById('pickOut').addEventListener('click', () => vscode.postMessage({ type: 'pickOutput' }));
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
@@ -426,23 +434,31 @@ function getHtml(): string {
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) vscode.postMessage({ type: 'openOutput', dir: outputDir });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
document.getElementById('convert').addEventListener('click', () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')]
|
||||
.filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
convertBtn.disabled = true;
|
||||
vscode.postMessage({
|
||||
type: 'convert',
|
||||
opts: {
|
||||
sourcePath: srcEl.value,
|
||||
outputDir: outEl.value,
|
||||
baseName: baseNameEl.value,
|
||||
networks,
|
||||
},
|
||||
});
|
||||
@@ -452,6 +468,8 @@ function getHtml(): string {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'sourcePicked') {
|
||||
srcEl.value = msg.path;
|
||||
srcNameEl.textContent = msg.path.split(/[\\\\/]/).filter(Boolean).pop() || msg.path;
|
||||
baseNameEl.value = msg.baseName || '';
|
||||
if (!outEl.value && msg.dir) {
|
||||
outEl.value = msg.dir;
|
||||
outputDir = msg.dir;
|
||||
@@ -466,31 +484,46 @@ function getHtml(): string {
|
||||
updateConvertBtn();
|
||||
if (msg.error) {
|
||||
statusEl.textContent = 'Error: ' + msg.error;
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
if (!results.length) {
|
||||
resultsEl.innerHTML = '';
|
||||
for (const r of results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
return;
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
resultsEl.innerHTML =
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:120px;">Network</th><th style="width:90px;">Status</th><th style="width:90px;">Output</th><th>Path</th><th>Error</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of results) {
|
||||
const row = document.createElement('tr');
|
||||
const net = document.createElement('td');
|
||||
net.textContent = r.network;
|
||||
const status = document.createElement('td');
|
||||
status.innerHTML = r.ok ? '<span class="badge ok">Done</span>' : '<span class="badge err">Failed</span>';
|
||||
const outputType = document.createElement('td');
|
||||
const detail = document.createElement('td');
|
||||
detail.className = 'mono wrap';
|
||||
const error = document.createElement('td');
|
||||
error.className = 'err wrap';
|
||||
if (r.ok) {
|
||||
outputType.textContent = /\\.zip$/i.test(r.file) ? 'ZIP' : 'HTML';
|
||||
detail.textContent = outputDisplayName(r.file);
|
||||
} else {
|
||||
outputType.textContent = '-';
|
||||
detail.innerHTML = '<span class="muted">-</span>';
|
||||
error.textContent = r.error || 'Unknown error';
|
||||
}
|
||||
if (r.ok) error.innerHTML = '<span class="muted">-</span>';
|
||||
row.append(net, status, outputType, detail, error);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
if (ok > 0) {
|
||||
outputDir = outEl.value;
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { File } from 'node:buffer';
|
||||
import { handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -15,6 +15,24 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'filesSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: true,
|
||||
@@ -159,78 +177,100 @@ function getPickerDefaultUri(): vscode.Uri | undefined {
|
||||
return vscode.Uri.file(root);
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
||||
th, td { padding: 6px 8px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
th { font-weight: 600; font-size: 12px; text-transform: uppercase; opacity: 0.7; }
|
||||
input[type=text] {
|
||||
width: 100%; padding: 4px 6px;
|
||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
${getToolWebviewStyles()}
|
||||
#rows input[type=text] { width: 100%; }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.row-error { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; }
|
||||
.actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
#status { margin-top: 12px; min-height: 20px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.result {
|
||||
margin-top: 12px; padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
word-break: break-all;
|
||||
.pick { min-width: 82px; }
|
||||
.remove-btn {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.result a { color: var(--vscode-textLink-foreground); }
|
||||
.result-actions { margin-top: 8px; display: flex; gap: 8px; }
|
||||
.remove-btn { background: transparent; color: var(--vscode-foreground); padding: 4px 8px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>PLEC Upload</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">PLEC Upload</h2>
|
||||
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||
</header>
|
||||
|
||||
<table id="rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:45%">HTML File</th>
|
||||
<th style="width:45%">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</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;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<button id="openResult" class="secondary" disabled>Open</button>
|
||||
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const rowsPanel = document.getElementById('rowsPanel');
|
||||
const emptySelection = document.getElementById('emptySelection');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\\.html?$/i, '');
|
||||
@@ -241,87 +281,143 @@ function getHtml(): string {
|
||||
return base;
|
||||
}
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const openResultBtn = document.getElementById('openResult');
|
||||
const copyResultBtn = document.getElementById('copyResult');
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = id;
|
||||
tr.innerHTML = \`
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<button class="pick secondary">Choose...</button>
|
||||
<span class="file-name" data-name>(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" data-path />
|
||||
<div class="row-error" data-error></div>
|
||||
</td>
|
||||
<td><input type="text" data-iter placeholder="iteration name" /></td>
|
||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
||||
\`;
|
||||
tr.querySelector('.pick').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFile', rowId: id });
|
||||
});
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
if (!tbody.children.length) addRow();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
document.getElementById('addRow').addEventListener('click', addRow);
|
||||
function addFiles(files) {
|
||||
const existing = new Set(rows.map(r => r.path));
|
||||
files.forEach(f => {
|
||||
if (existing.has(f.path)) return;
|
||||
rows.push({
|
||||
id: 'r' + (nextId++),
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
iteration: parseIterationName(f.name),
|
||||
error: ''
|
||||
});
|
||||
existing.add(f.path);
|
||||
});
|
||||
normalizeDuplicateIterations(files.length);
|
||||
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
renderRows();
|
||||
}
|
||||
|
||||
function normalizeDuplicateIterations(addedCount) {
|
||||
if (addedCount <= 1) return;
|
||||
const added = rows.slice(Math.max(0, rows.length - addedCount));
|
||||
if (!added.length) return;
|
||||
const first = added[0].iteration;
|
||||
if (first && added.every(r => r.iteration === first)) {
|
||||
added.forEach((r, i) => {
|
||||
r.iteration = String(i + 1).padStart(2, '0') + '_' + first;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
page = Math.min(page, maxPage);
|
||||
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||
const i = start + offset;
|
||||
return \`
|
||||
<tr data-id="\${row.id}">
|
||||
<td>
|
||||
<span class="mono wrap">\${escapeHtml(row.name)}</span>
|
||||
<div class="row-error" data-error>\${escapeHtml(row.error || '')}</div>
|
||||
</td>
|
||||
<td><input type="text" data-iter data-index="\${i}" value="\${escapeHtml(row.iteration)}" /></td>
|
||||
<td><button class="remove-btn danger" data-remove="\${i}" title="Remove">×</button></td>
|
||||
</tr>
|
||||
\`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
rows[Number(input.dataset.index)].iteration = input.value;
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
rows.splice(Number(btn.dataset.remove), 1);
|
||||
renderRows();
|
||||
});
|
||||
});
|
||||
if (rows.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
document.querySelector('#rows').parentElement.after(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||
}
|
||||
}
|
||||
|
||||
function setPreview(url) {
|
||||
previewUrl = url || '';
|
||||
openResultBtn.disabled = !previewUrl;
|
||||
copyResultBtn.disabled = !previewUrl;
|
||||
}
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFolder' });
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'pickFile' });
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
rows = [];
|
||||
page = 0;
|
||||
setPreview('');
|
||||
renderRows();
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
|
||||
document.getElementById('upload').addEventListener('click', () => {
|
||||
clearErrors();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
setPreview('');
|
||||
if (rows.length === 0) {
|
||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
||||
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||
return;
|
||||
}
|
||||
vscode.postMessage({ type: 'upload', rows });
|
||||
});
|
||||
openResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
|
||||
});
|
||||
copyResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) vscode.postMessage({ type: 'copy', text: previewUrl });
|
||||
});
|
||||
|
||||
function clearErrors() {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'filesSelected') {
|
||||
const firstTr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
const affected = [];
|
||||
m.files.forEach(function(f, i) {
|
||||
let tr;
|
||||
if (i === 0) {
|
||||
tr = firstTr;
|
||||
} else {
|
||||
addRow();
|
||||
tr = tbody.lastElementChild;
|
||||
}
|
||||
if (!tr) return;
|
||||
tr.querySelector('[data-path]').value = f.path;
|
||||
tr.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = tr.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(tr);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
addFiles(m.files || []);
|
||||
} else if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
statusEl.className = '';
|
||||
statusEl.className = 'status-panel';
|
||||
} else if (m.type === 'error') {
|
||||
statusEl.innerHTML = '';
|
||||
const div = document.createElement('div');
|
||||
@@ -329,47 +425,17 @@ function getHtml(): string {
|
||||
div.textContent = m.message;
|
||||
statusEl.appendChild(div);
|
||||
} else if (m.type === 'rowError') {
|
||||
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = m.message;
|
||||
const row = rows.find(r => r.id === m.rowId);
|
||||
if (row) row.error = m.message;
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
} else if (m.type === 'result') {
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (m.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + m.preview + '">' + m.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => vscode.postMessage({ type: 'copy', text: m.preview });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => vscode.postMessage({ type: 'open', text: m.preview });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = m.rawText || JSON.stringify(m.raw, null, 2);
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw JSON:</div><pre>' +
|
||||
(m.rawText || JSON.stringify(m.raw, null, 2)).replace(/</g, '<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(m.preview);
|
||||
}
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import { handleClipboardAndOpen } from './shared';
|
||||
import { getToolWebviewStyles, handleClipboardAndOpen } from './shared';
|
||||
|
||||
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
|
||||
|
||||
@@ -12,7 +12,11 @@ interface ActiveShare {
|
||||
server: http.Server;
|
||||
port: number;
|
||||
token: string;
|
||||
fileBuf: Buffer;
|
||||
files: SharedFile[];
|
||||
}
|
||||
|
||||
interface SharedFile {
|
||||
buf: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
@@ -57,19 +61,35 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
if (handleClipboardAndOpen(msg)) return;
|
||||
|
||||
if (msg.type === 'pickFolder') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectFolders: true,
|
||||
canSelectFiles: false,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Select Folder',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const files = await collectHtmlFiles(picked[0].fsPath);
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
files: files.map(filePath => ({ path: filePath, name: path.basename(filePath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pickFile') {
|
||||
const picked = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectMany: true,
|
||||
filters: { HTML: ['html', 'htm'] },
|
||||
openLabel: 'Select',
|
||||
defaultUri: getPickerDefaultUri(),
|
||||
});
|
||||
if (picked && picked[0]) {
|
||||
const fp = picked[0].fsPath;
|
||||
if (picked && picked.length) {
|
||||
panel.webview.postMessage({
|
||||
type: 'fileSelected',
|
||||
path: fp,
|
||||
name: path.basename(fp),
|
||||
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -77,13 +97,19 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
|
||||
if (msg.type === 'startShare') {
|
||||
stopActive();
|
||||
const filePath: string = msg.path;
|
||||
const filePaths: string[] = Array.isArray(msg.paths) ? msg.paths : (msg.path ? [msg.path] : []);
|
||||
if (!filePaths.length) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
const files: SharedFile[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
panel.webview.postMessage({ type: 'error', message: 'File missing.' });
|
||||
return;
|
||||
}
|
||||
const fileBuf = fs.readFileSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
files.push({ buf: fs.readFileSync(filePath), filename: path.basename(filePath) });
|
||||
}
|
||||
const token = crypto.randomBytes(6).toString('base64url');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.sendToMobile');
|
||||
@@ -93,8 +119,7 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
server: http.createServer(),
|
||||
port: 0,
|
||||
token,
|
||||
fileBuf,
|
||||
filename,
|
||||
files,
|
||||
};
|
||||
share.server.on('request', (req, res) => handleRequest(req, res, share));
|
||||
share.server.on('error', (err) => {
|
||||
@@ -125,7 +150,11 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
iface: ip.iface,
|
||||
url: `http://${ip.address}:${share.port}/s/${token}/`,
|
||||
}));
|
||||
panel.webview.postMessage({ type: 'sharing', urls, filename });
|
||||
panel.webview.postMessage({
|
||||
type: 'sharing',
|
||||
urls,
|
||||
filename: files.length === 1 ? files[0].filename : `${files.length} files`,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -141,6 +170,30 @@ export function openSendToMobile(context: vscode.ExtensionContext) {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectHtmlFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||||
stack.push(full);
|
||||
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, share: ActiveShare) {
|
||||
if (req.method !== 'GET' || !req.url) {
|
||||
res.statusCode = 404;
|
||||
@@ -156,7 +209,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
const rest = req.url.slice(prefix.length).replace(/\?.*$/, '');
|
||||
|
||||
if (rest === '' || rest === '/') {
|
||||
const body = chooserPage(share.filename);
|
||||
const body = chooserPage(share.files);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -165,24 +218,40 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
return;
|
||||
}
|
||||
|
||||
if (rest === '/view') {
|
||||
const viewMatch = rest.match(/^\/view\/(\d+)$/);
|
||||
if (rest === '/view' || viewMatch) {
|
||||
const index = viewMatch ? Number(viewMatch[1]) : 0;
|
||||
const file = share.files[index];
|
||||
if (!file) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rest === '/file') {
|
||||
const safeName = share.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const fileMatch = rest.match(/^\/file\/(\d+)$/);
|
||||
if (rest === '/file' || fileMatch) {
|
||||
const index = fileMatch ? Number(fileMatch[1]) : 0;
|
||||
const file = share.files[index];
|
||||
if (!file) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const safeName = file.filename.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||
'Content-Length': String(share.fileBuf.length),
|
||||
'Content-Length': String(file.buf.length),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(share.fileBuf);
|
||||
res.end(file.buf);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,10 +259,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse, shar
|
||||
res.end();
|
||||
}
|
||||
|
||||
function chooserPage(filename: string): string {
|
||||
const escName = filename.replace(/[&<>"]/g, (c) =>
|
||||
function chooserPage(files: SharedFile[]): string {
|
||||
const esc = (value: string) => value.replace(/[&<>"]/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)
|
||||
);
|
||||
const items = files.map((file, index) => {
|
||||
const escName = esc(file.filename);
|
||||
return `<div class="item">
|
||||
<div class="file">${escName}</div>
|
||||
<a class="btn view" href="view/${index}">View in browser</a>
|
||||
<a class="btn dl" href="file/${index}" download="${escName}">Download .html</a>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -203,7 +280,8 @@ function chooserPage(filename: string): string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
||||
.item { margin-bottom: 26px; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -211,9 +289,7 @@ function chooserPage(filename: string): string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">${escName}</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="${escName}">Download .html</a>
|
||||
${items}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
@@ -250,26 +326,8 @@ function getHtml(qrScriptUri: string): string {
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; }
|
||||
h2 { margin-top: 0; }
|
||||
.row { margin-bottom: 14px; }
|
||||
.file-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.file-name { opacity: 0.85; font-size: 12px; word-break: break-all; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 12px; border-radius: 2px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.secondary:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.iface-list { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
||||
.iface-list label { font-size: 12px; cursor: pointer; }
|
||||
.iface-list .meta { opacity: 0.6; margin-left: 6px; }
|
||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: 4px; }
|
||||
${getToolWebviewStyles()}
|
||||
#qr { margin-top: 14px; background: white; padding: 12px; display: inline-block; border-radius: var(--tool-radius); }
|
||||
#qr svg, #qr img { display: block; }
|
||||
.url {
|
||||
margin-top: 10px; padding: 8px;
|
||||
@@ -277,99 +335,168 @@ function getHtml(qrScriptUri: string): string {
|
||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
||||
font-family: var(--vscode-editor-font-family, monospace); font-size: 12px; word-break: break-all;
|
||||
}
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; }
|
||||
#status { margin-top: 12px; min-height: 18px; }
|
||||
.err { color: var(--vscode-errorForeground); white-space: pre-wrap; }
|
||||
.ok { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); }
|
||||
.hint { font-size: 12px; opacity: 0.7; margin-top: 8px; }
|
||||
.url-actions { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.share-panel { padding: 12px; }
|
||||
.iface-radio { text-align: center; }
|
||||
#qr { margin-top: 0; }
|
||||
.selected-list { margin-top: 10px; }
|
||||
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
<script src="${qrScriptUri}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Send To Mobile</h2>
|
||||
<p style="opacity:0.8;font-size:12px;margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<p class="tool-description">Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi. The phone gets View and Download options.</p>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<div class="file-cell">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span class="file-name" id="fileName">(no file)</span>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Inputs</h3>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button id="start">Start sharing</button>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div class="file-name" id="fileName" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="start">Start</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" class="row" style="display:none;">
|
||||
<div style="font-size:12px;opacity:0.8;">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div class="iface-list" id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr"></div>
|
||||
<div class="url" id="urlText"></div>
|
||||
<div class="url-actions">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="outputPanel" class="is-hidden">
|
||||
<div id="ifaceWrap" class="control-group" style="display:none;">
|
||||
<div class="control-label">Network Interfaces</div>
|
||||
<p class="section-description">Pick the interface your phone can reach.</p>
|
||||
<div class="iface-list" id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" class="result-card share-panel" style="display:none;">
|
||||
<div id="qr"></div>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||
First run on Windows may show a firewall prompt - allow access for "Private networks".
|
||||
Sharing stops automatically when this panel is closed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const outputPanel = document.getElementById('outputPanel');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedPaths = [];
|
||||
let selectedFiles = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
pickFolderBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFolder' }));
|
||||
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFile' }));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedFiles = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', () => {
|
||||
statusEl.innerHTML = '';
|
||||
const path = filePathEl.value;
|
||||
if (!path) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
vscode.postMessage({ type: 'startShare', path });
|
||||
vscode.postMessage({ type: 'startShare', paths: selectedPaths });
|
||||
});
|
||||
|
||||
stopBtn.addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'stopShare' });
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'copy', text: currentUrls[selectedIdx]?.url || '' });
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visibleFiles = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.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>' +
|
||||
visibleFiles.map((file, offset) => {
|
||||
const i = start + offset;
|
||||
return (
|
||||
'<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td>' +
|
||||
'<td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>'
|
||||
);
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.dataset.index);
|
||||
selectedFiles.splice(index, 1);
|
||||
selectedPaths.splice(index, 1);
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'open', text: currentUrls[selectedIdx]?.url || '' });
|
||||
});
|
||||
if (selectedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML =
|
||||
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection();
|
||||
|
||||
function renderQr(url) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -389,29 +516,42 @@ function getHtml(qrScriptUri: string): string {
|
||||
return;
|
||||
}
|
||||
ifaceWrap.style.display = '';
|
||||
ifaceList.innerHTML =
|
||||
'<div class="results-panel" style="margin-top:0;">' +
|
||||
'<table class="data-table">' +
|
||||
'<thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead>' +
|
||||
'<tbody></tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
const tbody = ifaceList.querySelector('tbody');
|
||||
urls.forEach((u, i) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + '<span class="meta">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML =
|
||||
'<td class="iface-radio"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td>' +
|
||||
'<td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === 'fileSelected') {
|
||||
filePathEl.value = m.path;
|
||||
fileNameEl.textContent = m.name;
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
selectedFiles = selectedFiles.concat((m.files || []).filter(f => !existing.has(f.path)));
|
||||
selectedPaths = selectedFiles.map(f => f.path);
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
} else if (m.type === 'sharing') {
|
||||
currentUrls = m.urls;
|
||||
selectedIdx = 0;
|
||||
statusEl.innerHTML = '<span class="ok">Sharing: ' + m.filename + '</span>';
|
||||
outputPanel.classList.remove('is-hidden');
|
||||
shareInfo.style.display = '';
|
||||
renderIfaces(m.urls);
|
||||
renderQr(m.urls[0].url);
|
||||
@@ -420,6 +560,8 @@ function getHtml(qrScriptUri: string): string {
|
||||
} else if (m.type === 'stopped') {
|
||||
statusEl.textContent = 'Stopped.';
|
||||
shareInfo.style.display = 'none';
|
||||
ifaceWrap.style.display = 'none';
|
||||
outputPanel.classList.add('is-hidden');
|
||||
currentUrls = [];
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
|
||||
@@ -13,6 +13,288 @@ export function handleClipboardAndOpen(msg: any): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getToolWebviewStyles(): string {
|
||||
return `
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--tool-gap-xs: 4px;
|
||||
--tool-gap-sm: 8px;
|
||||
--tool-gap-md: 12px;
|
||||
--tool-gap-lg: 16px;
|
||||
--tool-radius: 4px;
|
||||
--tool-border: var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
|
||||
--tool-panel: var(--vscode-editorWidget-background, var(--vscode-sideBar-background));
|
||||
--tool-panel-soft: var(--vscode-textBlockQuote-background, rgba(128, 128, 128, 0.08));
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
.tool-page {
|
||||
max-width: 1100px;
|
||||
padding: 18px;
|
||||
}
|
||||
.tool-header {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
}
|
||||
.tool-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-description,
|
||||
.section-description,
|
||||
.muted {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
.tool-description {
|
||||
margin: 5px 0 0;
|
||||
max-width: 780px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.tool-section {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
}
|
||||
.tool-panel {
|
||||
margin-bottom: var(--tool-gap-lg);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--tool-panel-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.is-hidden,
|
||||
.tool-panel.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--tool-gap-md);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--tool-border);
|
||||
background: var(--tool-panel);
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.45px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.control-group + .control-group {
|
||||
margin-top: var(--tool-gap-md);
|
||||
}
|
||||
.control-label {
|
||||
margin: 0 0 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0 0 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-description {
|
||||
margin: -2px 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.field-row,
|
||||
.file-row,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tool-gap-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-row + .field-row {
|
||||
margin-top: var(--tool-gap-sm);
|
||||
}
|
||||
input[type=text],
|
||||
input[type=date],
|
||||
select,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, var(--tool-border));
|
||||
border-radius: var(--tool-radius);
|
||||
font: inherit;
|
||||
}
|
||||
input[type=text]:focus,
|
||||
input[type=date]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
.field-row input[type=text] {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
button {
|
||||
min-height: 28px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border-color: var(--tool-border);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
button.danger {
|
||||
background: transparent;
|
||||
color: var(--vscode-errorForeground);
|
||||
border-color: var(--tool-border);
|
||||
}
|
||||
button.danger:hover {
|
||||
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.16));
|
||||
}
|
||||
.remove-selected {
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 3px 0;
|
||||
color: var(--vscode-errorForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.action-row {
|
||||
margin-top: var(--tool-gap-md);
|
||||
}
|
||||
.status-panel,
|
||||
.results-panel,
|
||||
.result-card {
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--tool-panel-soft);
|
||||
}
|
||||
.status-panel {
|
||||
min-height: 30px;
|
||||
margin-top: var(--tool-gap-md);
|
||||
padding: 8px 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.status-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
.results-panel {
|
||||
margin-top: var(--tool-gap-md);
|
||||
overflow: auto;
|
||||
}
|
||||
.results-panel:empty {
|
||||
display: none;
|
||||
}
|
||||
.output-panel .results-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
.result-card {
|
||||
padding: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--tool-border);
|
||||
}
|
||||
.data-table th {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
background: var(--tool-panel);
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wrap {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.file-name {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ok {
|
||||
color: var(--vscode-testing-iconPassed, #3fb950);
|
||||
}
|
||||
.err {
|
||||
color: var(--vscode-errorForeground, #f85149);
|
||||
}
|
||||
.warn {
|
||||
color: var(--vscode-editorWarning-foreground, #d29922);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tool-border);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.ok {
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #3fb950) 45%, transparent);
|
||||
background: rgba(63, 185, 80, 0.12);
|
||||
}
|
||||
.badge.err {
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f85149) 45%, transparent);
|
||||
background: var(--vscode-inputValidation-errorBackground, rgba(248, 81, 73, 0.12));
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: color-mix(in srgb, var(--vscode-editorWarning-foreground, #d29922) 45%, transparent);
|
||||
background: var(--vscode-inputValidation-warningBackground, rgba(210, 153, 34, 0.12));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function singletonPanel(
|
||||
store: { panel: vscode.WebviewPanel | null },
|
||||
viewType: string,
|
||||
|
||||
@@ -35,31 +35,50 @@ func pickApplovinUserAgent(randomize bool) string {
|
||||
|
||||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>AppLovin Playable Preview (QR)</h2>
|
||||
<p class="hint" style="margin-top:0;">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||||
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</p>
|
||||
</header>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<button id="pick" class="secondary">Add files...</button>
|
||||
<button id="clear" class="secondary" style="display:none;">Clear</button>
|
||||
<span id="fileName" class="hint">(no files)</span>
|
||||
</div>
|
||||
<div id="fileList" style="margin-bottom:8px;font-size:12px;opacity:0.85;"></div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="upload">Upload to AppLovin</button>
|
||||
<button id="saveAll" class="secondary" style="display:none;">Save All QRs...</button>
|
||||
<button id="regenAll" class="secondary" style="display:none;">Regenerate QRs</button>
|
||||
</div>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-files"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||||
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;"></div>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.result { padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.qr-cell { text-align:center; }
|
||||
.qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
|
||||
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.qr-results:not(.has-errors) .error-col { display:none; }
|
||||
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
|
||||
.qr-modal.open { display:flex; }
|
||||
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
|
||||
</style>
|
||||
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let selectedPage = 0;
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const uploadBtn = document.getElementById('upload');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
@@ -68,6 +87,7 @@ const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const saveAllBtn = document.getElementById('saveAll');
|
||||
const regenAllBtn = document.getElementById('regenAll');
|
||||
const qrModal = document.getElementById('qrModal');
|
||||
let selectedPaths = [];
|
||||
let resultItems = [];
|
||||
|
||||
@@ -76,15 +96,38 @@ function basename(p) {
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedPaths.length) {
|
||||
fileNameEl.textContent = '(no files)';
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||||
fileListEl.innerHTML = selectedPaths.map(p => '<div>' + escapeHtml(basename(p)) + '</div>').join('');
|
||||
clearBtn.style.display = '';
|
||||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.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>' +
|
||||
visiblePaths.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (selectedPaths.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
|
||||
saveAllBtn.addEventListener('click', async () => {
|
||||
@@ -114,15 +157,27 @@ regenAllBtn.addEventListener('click', () => {
|
||||
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
pickFolderBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
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();
|
||||
}
|
||||
});
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -136,8 +191,8 @@ uploadBtn.addEventListener('click', async () => {
|
||||
}
|
||||
uploadBtn.disabled = true;
|
||||
resultItems = [];
|
||||
saveAllBtn.style.display = 'none';
|
||||
regenAllBtn.style.display = 'none';
|
||||
saveAllBtn.disabled = true;
|
||||
regenAllBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/applovin/upload', {
|
||||
method: 'POST',
|
||||
@@ -174,24 +229,40 @@ function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function ensureResultsTable() {
|
||||
let tbody = resultsEl.querySelector('tbody');
|
||||
if (tbody) return tbody;
|
||||
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
|
||||
return resultsEl.querySelector('tbody');
|
||||
}
|
||||
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
|
||||
|
||||
function handle(m) {
|
||||
if (m.type === 'status') {
|
||||
statusEl.textContent = m.message;
|
||||
} else if (m.type === 'fileError') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML = '<div><strong>' + escapeHtml(m.name) + '</strong></div>' +
|
||||
'<div class="err" style="margin-top:6px;">' + escapeHtml(m.message) + '</div>';
|
||||
resultsEl.appendChild(wrap);
|
||||
const tbody = ensureResultsTable();
|
||||
resultsEl.querySelector('table').classList.add('has-errors');
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
} else if (m.type === 'fileResult') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
wrap.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:8px;">' + escapeHtml(m.name) + '</div>' +
|
||||
'<div style="text-align:center;margin-bottom:8px;">' +
|
||||
'<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" style="background:#fff;padding:8px;border-radius:4px;max-width:100%;" />' +
|
||||
'</div>' +
|
||||
'<div style="font-size:11px;opacity:0.8;">Hash: ' + escapeHtml(m.hash) + '</div>';
|
||||
const tbody = ensureResultsTable();
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = m.name;
|
||||
const qr = document.createElement('td');
|
||||
qr.className = 'qr-cell';
|
||||
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||||
qr.querySelector('img').addEventListener('click', (event) => {
|
||||
qrModal.querySelector('img').src = event.currentTarget.src;
|
||||
qrModal.classList.add('open');
|
||||
});
|
||||
const actionCell = document.createElement('td');
|
||||
const error = document.createElement('td');
|
||||
error.className = 'error-col';
|
||||
error.innerHTML = '<span class="muted">-</span>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
@@ -218,13 +289,12 @@ function handle(m) {
|
||||
}
|
||||
};
|
||||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||||
wrap.appendChild(actions);
|
||||
resultsEl.appendChild(wrap);
|
||||
actionCell.appendChild(actions);
|
||||
row.append(file, qr, actionCell, error);
|
||||
tbody.appendChild(row);
|
||||
resultItems.push({ name: m.name, url: m.qrImg });
|
||||
if (resultItems.length >= 2) {
|
||||
saveAllBtn.style.display = '';
|
||||
regenAllBtn.style.display = '';
|
||||
}
|
||||
saveAllBtn.disabled = resultItems.length === 0;
|
||||
regenAllBtn.disabled = resultItems.length === 0;
|
||||
} else if (m.type === 'done') {
|
||||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||||
}
|
||||
@@ -232,7 +302,7 @@ function handle(m) {
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Upload", body)))
|
||||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
|
||||
}
|
||||
|
||||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -253,6 +323,23 @@ func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
cfg.LastPickDir = dir
|
||||
_ = SaveConfig(cfg)
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Paths []string `json:"paths"`
|
||||
|
||||
@@ -12,83 +12,119 @@ import (
|
||||
|
||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Base64 Scanner</h2>
|
||||
<p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
|
||||
</header>
|
||||
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
||||
.summary { opacity:0.8; margin-bottom:6px; }
|
||||
.asset-list { margin:0; padding-left:16px; }
|
||||
.asset-list li + li { margin-top:3px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const PAGE_SIZE = 5;
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
let selectedPage = 0;
|
||||
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 renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!pickedFiles.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||
selectionEl.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>' +
|
||||
visible.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (pickedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addPaths(paths) {
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
if (!pickedFiles.length) { 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; }
|
||||
const total = j.results.length;
|
||||
const flagged = j.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
|
||||
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>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
||||
mark.textContent = rr.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = rr.file;
|
||||
row.appendChild(mark); row.appendChild(name);
|
||||
if (!rr.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + rr.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
const row = document.createElement('tr');
|
||||
const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
|
||||
row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -99,7 +135,11 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
|
||||
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
p := PickFolder("Select folder to scan", "")
|
||||
writeJSON(w, map[string]any{"path": p})
|
||||
if p == "" {
|
||||
writeJSON(w, map[string]any{"paths": []string{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
|
||||
}
|
||||
|
||||
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
116
standalone/changelog.go
Normal file
116
standalone/changelog.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ChangelogPage(w http.ResponseWriter, r *http.Request) {
|
||||
markdown := readChangelog()
|
||||
body := `
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Changelog</h2>
|
||||
<p class="tool-description">Loaded from CHANGELOG.md.</p>
|
||||
</header>
|
||||
<section class="tool-panel changelog-panel">
|
||||
<div class="panel-body changelog-content">
|
||||
` + renderChangelogMarkdown(markdown) + `
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.changelog-panel { max-width: 840px; }
|
||||
.changelog-content h1 { margin: 0 0 14px; font-size: 20px; line-height: 1.25; }
|
||||
.changelog-content h2 { margin: 18px 0 8px; padding-top: 12px; border-top: 1px solid #333; font-size: 15px; line-height: 1.3; }
|
||||
.changelog-content ul { margin: 6px 0 12px; padding-left: 20px; }
|
||||
.changelog-content li { margin: 4px 0; line-height: 1.45; }
|
||||
.changelog-content p { margin: 8px 0; line-height: 1.5; }
|
||||
</style>`
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/changelog", "Changelog", body)))
|
||||
}
|
||||
|
||||
func readChangelog() string {
|
||||
for _, start := range changelogSearchRoots() {
|
||||
for _, candidate := range changelogCandidates(start) {
|
||||
data, err := os.ReadFile(candidate)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "# Changelog\n\nCHANGELOG.md was not found."
|
||||
}
|
||||
|
||||
func changelogSearchRoots() []string {
|
||||
roots := []string{}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
roots = append(roots, wd)
|
||||
}
|
||||
roots = append(roots, appDir())
|
||||
return roots
|
||||
}
|
||||
|
||||
func changelogCandidates(start string) []string {
|
||||
candidates := []string{}
|
||||
dir := filepath.Clean(start)
|
||||
for i := 0; i < 5; i++ {
|
||||
candidates = append(candidates, filepath.Join(dir, "CHANGELOG.md"))
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func renderChangelogMarkdown(markdown string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
|
||||
var out strings.Builder
|
||||
inList := false
|
||||
closeList := func() {
|
||||
if inList {
|
||||
out.WriteString("</ul>")
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "### ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[4:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
closeList()
|
||||
out.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(line[3:])) + "</h2>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
closeList()
|
||||
out.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</h1>")
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
if !inList {
|
||||
out.WriteString("<ul>")
|
||||
inList = true
|
||||
}
|
||||
out.WriteString("<li>" + html.EscapeString(strings.TrimSpace(line[2:])) + "</li>")
|
||||
continue
|
||||
}
|
||||
closeList()
|
||||
out.WriteString("<p>" + html.EscapeString(line) + "</p>")
|
||||
}
|
||||
closeList()
|
||||
return out.String()
|
||||
}
|
||||
@@ -12,19 +12,40 @@ func HomePage(w http.ResponseWriter, r *http.Request) {
|
||||
if n.Path == "/" {
|
||||
continue
|
||||
}
|
||||
label := n.Label
|
||||
badge := ""
|
||||
if n.Beta {
|
||||
label += ` <span style="font-size:10px;opacity:0.65;text-transform:uppercase;">Beta</span>`
|
||||
badge = ` <span class="beta-pill">Beta</span>`
|
||||
}
|
||||
items.WriteString(`<li><a href="` + n.Path + `" style="color:#9cdcfe;">` + label + `</a> — ` + n.Description + `</li>`)
|
||||
items.WriteString(`<button type="button" class="home-tool" data-path="` + n.Path + `"><span class="home-tool-title">` + n.Label + badge + `</span><span class="home-tool-desc">` + n.Description + `</span></button>`)
|
||||
}
|
||||
|
||||
body := `
|
||||
<h2>HPL Toolbox</h2>
|
||||
<p class="hint">Standalone build. Pick a tool from the top bar.</p>
|
||||
<ul style="line-height:1.9;">
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">HPL Toolbox</h2>
|
||||
<p class="tool-description">Standalone build. Pick a tool to open.</p>
|
||||
</header>
|
||||
<div class="home-tools">
|
||||
` + items.String() + `
|
||||
</ul>
|
||||
</div>
|
||||
<style>
|
||||
.home-tools { display:flex; flex-direction:column; gap:8px; max-width:620px; }
|
||||
.home-tool {
|
||||
display:block;
|
||||
width:100%;
|
||||
min-height:52px;
|
||||
padding:9px 11px;
|
||||
color:#ddd;
|
||||
text-align:left;
|
||||
background:#2a2d2e;
|
||||
border:1px solid #333;
|
||||
border-radius:5px;
|
||||
cursor:pointer;
|
||||
font:inherit;
|
||||
}
|
||||
.home-tool:hover { background:#33373a; border-color:#007fd4; }
|
||||
.home-tool-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:600; }
|
||||
.home-tool-desc { display:block; margin-top:4px; color:#a7a7a7; font-size:11px; line-height:1.35; }
|
||||
</style>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(Page("/", "Home", body)))
|
||||
|
||||
@@ -3,13 +3,25 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SharedCSS = `
|
||||
:root { color-scheme: dark; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--tool-gap-sm: 8px;
|
||||
--tool-gap-md: 12px;
|
||||
--tool-gap-lg: 16px;
|
||||
--tool-radius: 5px;
|
||||
--tool-border: #333;
|
||||
--tool-panel: #252526;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #1e1e1e; color: #ddd;
|
||||
margin: 0; padding: 0;
|
||||
@@ -19,17 +31,61 @@ const SharedCSS = `
|
||||
padding: 8px 16px; background: #252526; border-bottom: 1px solid #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
.topbar a { color: #9cdcfe; text-decoration: none; padding: 4px 10px; border-radius: 3px; }
|
||||
.topbar a:hover { background: #2a2d2e; }
|
||||
.topbar a.active { background: #094771; color: #fff; }
|
||||
.topbar .nav-link {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.topbar .nav-link:hover { background: #2a2d2e; }
|
||||
.topbar .nav-link.active { background: #094771; color: #fff; }
|
||||
.beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; }
|
||||
.topbar-version { margin-left: auto; font-size: 11px; opacity: 0.45; letter-spacing: 0.4px; }
|
||||
.content { padding: 16px; max-width: 980px; margin: 0 auto; }
|
||||
.beta-tools-footer { max-width: 980px; margin: 24px auto 16px auto; padding: 12px 16px 0; border-top: 1px solid #333; }
|
||||
.beta-tools-footer button { width: 100%; text-align: left; background: #3a3d41; color: #ddd; }
|
||||
.beta-tools-footer button:hover { background: #45494e; }
|
||||
.beta-note { display: block; margin-top: 3px; opacity: 0.65; font-size: 11px; }
|
||||
h2 { margin-top: 0; font-weight: 500; }
|
||||
.topbar-spacer { margin-left: auto; }
|
||||
.content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; }
|
||||
.app-footer {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: auto auto 0;
|
||||
padding: 8px 18px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #a7a7a7;
|
||||
font-size: 10px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
.beta-tools-toggle {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #a7a7a7;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
opacity: 0.72;
|
||||
text-align: left;
|
||||
}
|
||||
.beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||
.app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; }
|
||||
.app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; }
|
||||
.app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; }
|
||||
h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||
.tool-header { margin-bottom: var(--tool-gap-lg); }
|
||||
.tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; }
|
||||
.tool-description, .section-description, .muted { color: #a7a7a7; }
|
||||
.tool-description { margin: 5px 0 0; max-width: 780px; font-size: 12px; line-height: 1.45; }
|
||||
.tool-panel { margin-bottom: var(--tool-gap-lg); border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); overflow: hidden; }
|
||||
.panel-header { padding: 10px 12px; border-bottom: 1px solid var(--tool-border); background: var(--tool-panel); }
|
||||
.panel-title { margin: 0; color: #ddd; font-size: 12px; font-weight: 700; letter-spacing: 0.45px; text-transform: uppercase; }
|
||||
.panel-body { padding: 12px; }
|
||||
.control-group + .control-group { margin-top: var(--tool-gap-md); }
|
||||
.control-label { margin: 0 0 6px; color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; }
|
||||
.file-row, .field-row, .action-row { display: flex; align-items: center; gap: var(--tool-gap-sm); flex-wrap: wrap; }
|
||||
.action-row { margin-top: var(--tool-gap-md); }
|
||||
input[type=text], input[type=date], select, textarea {
|
||||
box-sizing: border-box; padding: 6px 8px;
|
||||
background: #3c3c3c; color: #ddd;
|
||||
@@ -46,7 +102,30 @@ const SharedCSS = `
|
||||
button:hover { background: #1177bb; }
|
||||
button.secondary { background: #3a3d41; color: #ddd; }
|
||||
button.secondary:hover { background: #45494e; }
|
||||
button.danger { background: transparent; color: #f48771; border: 1px solid #444; }
|
||||
button.danger:hover { background: rgba(244,135,113,0.14); }
|
||||
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:empty { display: none; }
|
||||
.results-panel { margin-top: var(--tool-gap-md); overflow: auto; }
|
||||
.results-panel:empty { display: none; }
|
||||
.result-card { padding: 10px; word-break: break-word; }
|
||||
.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--tool-border); }
|
||||
.data-table th { color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; background: var(--tool-panel); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.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; }
|
||||
.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; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 18px; padding: 1px 6px; border-radius: 999px; border: 1px solid #444; font-size: 11px; line-height: 1.2; white-space: nowrap; }
|
||||
.badge.ok { color: #89d185; background: rgba(137,209,133,0.12); }
|
||||
.badge.err { color: #f48771; background: rgba(244,135,113,0.14); }
|
||||
.badge.warn { color: #ffd580; background: rgba(210,153,34,0.16); }
|
||||
.is-hidden { display: none !important; }
|
||||
.err { color: #f48771; white-space: pre-wrap; }
|
||||
.ok { color: #89d185; }
|
||||
.hint { font-size: 12px; opacity: 0.7; }
|
||||
@@ -62,7 +141,7 @@ type navItem struct {
|
||||
var navItems = []navItem{
|
||||
{Path: "/", Label: "Home"},
|
||||
{Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"},
|
||||
{Path: "/applovin", Label: "AppLovin Upload", 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: "/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"},
|
||||
@@ -109,20 +188,18 @@ func Page(activePath, title, body string) string {
|
||||
for _, n := range visibleNavItems(betaToolsEnabled) {
|
||||
cls := ""
|
||||
if n.Path == activePath {
|
||||
cls = " class=\"active\""
|
||||
cls = " active"
|
||||
}
|
||||
label := n.Label
|
||||
if n.Beta {
|
||||
label += `<span class="beta-pill">Beta</span>`
|
||||
}
|
||||
tabs.WriteString(`<a href="` + n.Path + `"` + cls + `>` + label + `</a>`)
|
||||
tabs.WriteString(`<button type="button" class="nav-link` + cls + `" data-path="` + n.Path + `">` + label + `</button>`)
|
||||
}
|
||||
|
||||
betaButtonLabel := "Enable Beta Tools"
|
||||
betaButtonNote := "Hidden beta tools: " + strconv.Itoa(betaToolCount())
|
||||
betaButtonLabel := "Show beta"
|
||||
if betaToolsEnabled {
|
||||
betaButtonLabel = "Disable Beta Tools"
|
||||
betaButtonNote = "Beta tools are visible in this standalone launcher."
|
||||
betaButtonLabel = "Hide beta"
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -131,12 +208,20 @@ func Page(activePath, title, body string) string {
|
||||
<title>` + title + ` — HPL Toolbox</title>
|
||||
<style>` + SharedCSS + `</style>
|
||||
</head><body>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-version">v` + AppVersion + `</span></div>
|
||||
<div class="topbar">` + tabs.String() + `<span class="topbar-spacer"></span></div>
|
||||
<div class="content">` + body + `</div>
|
||||
<div class="beta-tools-footer">
|
||||
<button id="betaToolsToggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `<span class="beta-note">` + betaButtonNote + `</span></button>
|
||||
<div class="app-footer">
|
||||
<button id="betaToolsToggle" class="beta-tools-toggle" data-enabled="` + boolAttr(betaToolsEnabled) + `">` + betaButtonLabel + `</button>
|
||||
<span class="app-footer-version">` + AppVersion + ` - JJGC 00784</span>
|
||||
<button type="button" class="app-footer-link" data-path="/changelog">Changelog</button>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('[data-path]').forEach((element) => {
|
||||
element.addEventListener('click', () => {
|
||||
const path = element.dataset.path;
|
||||
if (path) window.location.href = path;
|
||||
});
|
||||
});
|
||||
document.getElementById('betaToolsToggle').addEventListener('click', async (event) => {
|
||||
const enabled = event.currentTarget.dataset.enabled === 'true';
|
||||
await fetch('/api/betaTools', {
|
||||
|
||||
@@ -113,6 +113,7 @@ func buildMux() *http.ServeMux {
|
||||
mux.HandleFunc("GET /mraid", MraidPage)
|
||||
mux.HandleFunc("GET /mobile", MobilePage)
|
||||
mux.HandleFunc("GET /playworks", PlayworksPage)
|
||||
mux.HandleFunc("GET /changelog", ChangelogPage)
|
||||
mux.HandleFunc("GET /assets/qrcode.min.js", MobileQrScript)
|
||||
|
||||
// Shared API
|
||||
@@ -123,10 +124,12 @@ func buildMux() *http.ServeMux {
|
||||
|
||||
// PLEC
|
||||
mux.HandleFunc("POST /api/plec/pick", PlecPick)
|
||||
mux.HandleFunc("POST /api/plec/pickFolder", PlecPickFolder)
|
||||
mux.HandleFunc("POST /api/plec/upload", PlecUpload)
|
||||
|
||||
// AppLovin
|
||||
mux.HandleFunc("POST /api/applovin/pick", ApplovinPick)
|
||||
mux.HandleFunc("POST /api/applovin/pickFolder", ApplovinPickFolder)
|
||||
mux.HandleFunc("POST /api/applovin/upload", ApplovinUpload)
|
||||
mux.HandleFunc("POST /api/applovin/saveQr", ApplovinSaveQR)
|
||||
mux.HandleFunc("POST /api/applovin/saveAllQrs", ApplovinSaveAllQRs)
|
||||
@@ -143,6 +146,7 @@ func buildMux() *http.ServeMux {
|
||||
|
||||
// Send To Mobile
|
||||
mux.HandleFunc("POST /api/mobile/pick", MobilePick)
|
||||
mux.HandleFunc("POST /api/mobile/pickFolder", MobilePickFolder)
|
||||
mux.HandleFunc("POST /api/mobile/start", MobileStart)
|
||||
mux.HandleFunc("POST /api/mobile/stop", MobileStop)
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
@@ -27,7 +27,11 @@ type activeShare struct {
|
||||
listener net.Listener
|
||||
port int
|
||||
token string
|
||||
fileBuf []byte
|
||||
files []sharedFile
|
||||
}
|
||||
|
||||
type sharedFile struct {
|
||||
buf []byte
|
||||
filename string
|
||||
}
|
||||
|
||||
@@ -54,81 +58,143 @@ func MobileQrScript(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func MobilePage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Send To Mobile</h2>
|
||||
<p class="hint" style="margin-top:0;">
|
||||
Pick an HTML file, then start sharing. Scan the QR with a phone on the same WiFi.
|
||||
The phone gets a choice of <strong>View</strong> or <strong>Download</strong>.
|
||||
</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Send To Mobile</h2>
|
||||
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button id="pick" class="secondary">Choose...</button>
|
||||
<span id="fileName" class="hint">(no file)</span>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pick" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<input type="hidden" id="filePath" />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<button id="start">Start sharing</button>
|
||||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||||
<div id="fileList" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="start">Start</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="ifaceWrap" style="display:none;margin-bottom:14px;">
|
||||
<div class="hint">Multiple network interfaces found. Pick the one your phone can reach:</div>
|
||||
<div id="ifaceList" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" style="display:none;">
|
||||
<div id="qr" style="margin-top:14px;background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="url" id="urlText" style="margin-top:10px;padding:8px;background:#252526;border-left:3px solid #007fd4;font-family:Consolas, monospace;font-size:12px;word-break:break-all;"></div>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;">
|
||||
<button id="copyUrl" class="secondary">Copy URL</button>
|
||||
<button id="openUrl" class="secondary">Open in browser</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:8px;">
|
||||
First run on Windows may show a firewall prompt — allow access for "Private networks".
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
|
||||
<div class="control-label">Network Interfaces</div>
|
||||
<div id="ifaceList"></div>
|
||||
</div>
|
||||
|
||||
<div id="shareInfo" class="result-card" style="display:none;">
|
||||
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||||
First run on Windows may show a firewall prompt - allow access for Private networks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:18px;"></div>
|
||||
|
||||
<script src="/assets/qrcode.min.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
const pickFolderBtn = document.getElementById('pickFolder');
|
||||
const pickBtn = document.getElementById('pick');
|
||||
const clearBtn = document.getElementById('clear');
|
||||
const startBtn = document.getElementById('start');
|
||||
const stopBtn = document.getElementById('stop');
|
||||
const fileNameEl = document.getElementById('fileName');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const fileListEl = document.getElementById('fileList');
|
||||
const statusEl = document.getElementById('status');
|
||||
const shareInfo = document.getElementById('shareInfo');
|
||||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||||
const ifaceList = document.getElementById('ifaceList');
|
||||
const qrEl = document.getElementById('qr');
|
||||
const urlTextEl = document.getElementById('urlText');
|
||||
|
||||
let currentUrls = [];
|
||||
let selectedIdx = 0;
|
||||
let selectedFiles = [];
|
||||
let selectedPaths = [];
|
||||
let selectedPage = 0;
|
||||
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!selectedFiles.length) {
|
||||
fileNameEl.textContent = '(no files selected)';
|
||||
fileListEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
|
||||
fileListEl.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>' +
|
||||
visible.map((file, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = Number(btn.dataset.index);
|
||||
selectedFiles.splice(index, 1);
|
||||
selectedPaths.splice(index, 1);
|
||||
renderSelection();
|
||||
});
|
||||
});
|
||||
if (selectedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
fileListEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addFiles(files) {
|
||||
const existing = new Set(selectedFiles.map(f => f.path));
|
||||
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
|
||||
selectedPaths = selectedFiles.map(f => f.path);
|
||||
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
pickFolderBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
|
||||
const j = await r.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
pickBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) {
|
||||
filePathEl.value = j.path;
|
||||
fileNameEl.textContent = j.name;
|
||||
}
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
clearBtn.addEventListener('click', () => {
|
||||
selectedFiles = [];
|
||||
selectedPaths = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
renderSelection();
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
statusEl.innerHTML = '';
|
||||
if (!filePathEl.value) {
|
||||
statusEl.innerHTML = '<span class="err">Pick an HTML file first.</span>';
|
||||
if (!selectedPaths.length) {
|
||||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||||
return;
|
||||
}
|
||||
startBtn.disabled = true;
|
||||
statusEl.textContent = 'Starting server...';
|
||||
const r = await fetch('/api/mobile/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ path: filePathEl.value }),
|
||||
body: JSON.stringify({ paths: selectedPaths }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
@@ -154,18 +220,8 @@ stopBtn.addEventListener('click', async () => {
|
||||
stopBtn.disabled = true;
|
||||
});
|
||||
|
||||
document.getElementById('copyUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/clipboard', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:url})});
|
||||
});
|
||||
document.getElementById('openUrl').addEventListener('click', () => {
|
||||
const url = currentUrls[selectedIdx]?.url || '';
|
||||
fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
||||
});
|
||||
|
||||
function renderQr(url) {
|
||||
qrEl.innerHTML = '';
|
||||
urlTextEl.textContent = url;
|
||||
try {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
@@ -182,19 +238,18 @@ function renderIfaces(urls) {
|
||||
ifaceList.innerHTML = '';
|
||||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||||
ifaceWrap.style.display = '';
|
||||
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
|
||||
const tbody = ifaceList.querySelector('tbody');
|
||||
urls.forEach((u, i) => {
|
||||
const id = 'iface_' + i;
|
||||
const label = document.createElement('label');
|
||||
label.style.fontSize = '12px';
|
||||
label.style.cursor = 'pointer';
|
||||
label.innerHTML = '<input type="radio" name="iface" id="' + id + '"' +
|
||||
(i === selectedIdx ? ' checked' : '') + ' /> ' +
|
||||
u.ip + ' <span style="opacity:0.6;">(' + u.iface + ')</span>';
|
||||
label.querySelector('input').addEventListener('change', () => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||||
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
|
||||
row.querySelector('input').addEventListener('change', () => {
|
||||
selectedIdx = i;
|
||||
renderQr(urls[i].url);
|
||||
});
|
||||
ifaceList.appendChild(label);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -207,13 +262,31 @@ func MobilePick(w http.ResponseWriter, r *http.Request) {
|
||||
picked := PickFiles(
|
||||
"Select HTML",
|
||||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||||
false, "",
|
||||
true, "",
|
||||
)
|
||||
if len(picked) == 0 {
|
||||
writeJSON(w, map[string]any{"path": nil})
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "name": filepath.Base(picked[0])})
|
||||
files := make([]map[string]string, 0, len(picked))
|
||||
for _, p := range picked {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func MobilePickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
dir := PickFolder("Select folder", "")
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -221,21 +294,33 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Path == "" || !fileExists(req.Path) {
|
||||
paths := req.Paths
|
||||
if len(paths) == 0 && req.Path != "" {
|
||||
paths = []string{req.Path}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(req.Path)
|
||||
files := make([]sharedFile, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if p == "" || !fileExists(p) {
|
||||
writeJSON(w, map[string]any{"error": "File missing."})
|
||||
return
|
||||
}
|
||||
fileBuf, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(req.Path)
|
||||
files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)})
|
||||
}
|
||||
|
||||
// 6 random bytes -> base64url, no padding
|
||||
tokenBytes := make([]byte, 6)
|
||||
@@ -257,8 +342,7 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
listener: listener,
|
||||
port: port,
|
||||
token: token,
|
||||
fileBuf: fileBuf,
|
||||
filename: filename,
|
||||
files: files,
|
||||
}
|
||||
share.server = &http.Server{Handler: shareHandler(share)}
|
||||
|
||||
@@ -288,6 +372,10 @@ func MobileStart(w http.ResponseWriter, r *http.Request) {
|
||||
URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token),
|
||||
})
|
||||
}
|
||||
filename := fmt.Sprintf("%d files", len(files))
|
||||
if len(files) == 1 {
|
||||
filename = files[0].filename
|
||||
}
|
||||
writeJSON(w, map[string]any{"urls": urls, "filename": filename})
|
||||
}
|
||||
|
||||
@@ -308,19 +396,53 @@ func shareHandler(s *activeShare) http.Handler {
|
||||
case "", "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(chooserPage(s.filename)))
|
||||
_, _ = w.Write([]byte(chooserPage(s.files)))
|
||||
case "/view":
|
||||
fallthrough
|
||||
case "/view/0":
|
||||
if len(s.files) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
case "/file":
|
||||
safeName := safeFilename(s.filename)
|
||||
fallthrough
|
||||
case "/file/0":
|
||||
if len(s.files) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
safeName := safeFilename(s.files[0].filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.fileBuf)))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(s.fileBuf)
|
||||
_, _ = w.Write(s.files[0].buf)
|
||||
default:
|
||||
if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") {
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
if len(parts) == 2 {
|
||||
idx, err := strconv.Atoi(parts[1])
|
||||
if err == nil && idx >= 0 && idx < len(s.files) {
|
||||
file := s.files[idx]
|
||||
if parts[0] == "view" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(file.buf)
|
||||
return
|
||||
}
|
||||
safeName := safeFilename(file.filename)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(file.buf)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(file.buf)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
@@ -330,8 +452,14 @@ var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`)
|
||||
|
||||
func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") }
|
||||
|
||||
func chooserPage(filename string) string {
|
||||
escName := html.EscapeString(filename)
|
||||
func chooserPage(files []sharedFile) string {
|
||||
var items strings.Builder
|
||||
for i, file := range files {
|
||||
escName := html.EscapeString(file.filename)
|
||||
items.WriteString(`<div class="item"><div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
|
||||
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
|
||||
}
|
||||
return `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -341,7 +469,8 @@ func chooserPage(filename string) string {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 28px; word-break: break-all; }
|
||||
.item { margin-bottom: 26px; }
|
||||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||||
a.view { background: #2d7dff; color: white; }
|
||||
@@ -349,9 +478,7 @@ func chooserPage(filename string) string {
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>HPL Toolbox</h1>
|
||||
<div class="file">` + escName + `</div>
|
||||
<a class="btn view" href="view">View in browser</a>
|
||||
<a class="btn dl" href="file" download="` + escName + `">Download .html</a>
|
||||
` + items.String() + `
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
|
||||
@@ -41,17 +41,26 @@ type mraidContext struct {
|
||||
|
||||
func MraidPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>MRAID Checker</h2>
|
||||
<p class="hint">Static checks for AppLovin, ironSource, and Unity HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">MRAID Checker</h2>
|
||||
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</p>
|
||||
</header>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</div>
|
||||
<div id="selection" class="selected-list"></div>
|
||||
</div>
|
||||
<div class="action-row"><button id="scan">Scan</button></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<style>
|
||||
.row-result { padding:8px 0; border-bottom:1px solid #333; }
|
||||
@@ -75,35 +84,73 @@ const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
const PAGE_SIZE = 5;
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
let selectedPage = 0;
|
||||
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])); }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
|
||||
function renderSelection() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
if (!pickedFiles.length) {
|
||||
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
selectedPage = Math.min(selectedPage, maxPage);
|
||||
const start = selectedPage * PAGE_SIZE;
|
||||
const visible = pickedFiles.slice(start, start + PAGE_SIZE);
|
||||
selectionEl.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>' +
|
||||
visible.map((p, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr><td class="mono wrap">' + escapeText(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||||
}).join('') + '</tbody></table></div>';
|
||||
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||||
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||||
});
|
||||
if (pickedFiles.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
selectionEl.appendChild(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||||
}
|
||||
}
|
||||
function addPaths(paths) {
|
||||
const existing = new Set(pickedFiles);
|
||||
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
|
||||
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
|
||||
renderSelection();
|
||||
}
|
||||
renderSelection();
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
addPaths(j.paths || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
pickedFiles = [];
|
||||
selectedPage = 0;
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
renderSelection();
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
if (!pickedFiles.length) { 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; }
|
||||
@@ -112,15 +159,19 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
|
||||
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
|
||||
const skipped = Number(j.skipped || 0);
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' non-target HTML file(s).' : '';
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' AppLovin/ironSource/Unity HTML file(s). ' + withIssues + ' file(s) need review. ' + requirements + ' requirement issue(s), ' + bestPractices + ' best-practice warning(s).' + skippedText + '</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
const skippedReason = j.skippedReason || 'file(s).';
|
||||
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
|
||||
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
|
||||
if (!total) { resultsEl.innerHTML = ''; return; }
|
||||
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const rr of j.results) {
|
||||
const reqCount = rr.issues.filter(i => i.severity === 'requirement').length;
|
||||
const bpCount = rr.issues.filter(i => i.severity === 'best-practice').length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
row.innerHTML = '<div class="file-head"><span class="mark ' + (rr.ok ? 'ok' : 'bad') + '">' + (rr.ok ? 'OK' : 'X') + '</span><span class="file-name">' + escapeText(rr.file) + '</span><span class="counts">' + reqCount + ' req / ' + bpCount + ' bp</span></div>';
|
||||
const row = document.createElement('tr');
|
||||
const file = document.createElement('td');
|
||||
file.className = 'mono wrap';
|
||||
file.textContent = rr.file;
|
||||
const result = document.createElement('td');
|
||||
result.className = 'wrap';
|
||||
if (!rr.ok) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'issues';
|
||||
@@ -131,9 +182,12 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
|
||||
list.appendChild(item);
|
||||
}
|
||||
row.appendChild(list);
|
||||
result.appendChild(list);
|
||||
} else {
|
||||
result.innerHTML = '<span class="badge ok">OK</span>';
|
||||
}
|
||||
resultsEl.appendChild(row);
|
||||
row.append(file, result);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -143,7 +197,18 @@ document.getElementById('scan').addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"path": PickFolder("Select folder to scan", "")})
|
||||
p := PickFolder("Select folder to scan", "")
|
||||
if p == "" {
|
||||
writeJSON(w, map[string]any{"paths": []string{}})
|
||||
return
|
||||
}
|
||||
files := []string{}
|
||||
for _, f := range collectHTMLFiles(p) {
|
||||
if isInSupportedMraidFolderPath(f) {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]any{"paths": files})
|
||||
}
|
||||
|
||||
func MraidPickFiles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -164,6 +229,8 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
var targets []string
|
||||
var baseDir string
|
||||
skipped := 0
|
||||
skippedReason := ""
|
||||
noTargetsError := "No HTML files were found."
|
||||
if len(req.Files) > 0 {
|
||||
var existing []string
|
||||
for _, f := range req.Files {
|
||||
@@ -171,30 +238,30 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
existing = append(existing, f)
|
||||
}
|
||||
}
|
||||
for _, f := range existing {
|
||||
if isSupportedMraidBuild(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(existing) - len(targets)
|
||||
targets = existing
|
||||
skipped = len(req.Files) - len(existing)
|
||||
skippedReason = "missing file(s)."
|
||||
noTargetsError = "No selected HTML files were found."
|
||||
if len(targets) > 0 {
|
||||
baseDir = commonBaseDir(targets)
|
||||
}
|
||||
} else if req.Folder != "" && fileExists(req.Folder) {
|
||||
all := collectHTMLFiles(req.Folder)
|
||||
for _, f := range all {
|
||||
if isSupportedMraidBuild(f) {
|
||||
if isInSupportedMraidFolderPath(f) {
|
||||
targets = append(targets, f)
|
||||
}
|
||||
}
|
||||
skipped = len(all) - len(targets)
|
||||
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
|
||||
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
|
||||
baseDir = req.Folder
|
||||
} else {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "error": "No AppLovin, ironSource, or Unity HTML builds were found."})
|
||||
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -215,13 +282,12 @@ func MraidScan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
|
||||
}
|
||||
writeJSON(w, map[string]any{"results": results, "skipped": skipped})
|
||||
writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason})
|
||||
}
|
||||
|
||||
var (
|
||||
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
|
||||
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
|
||||
mraidBuildNameRx = regexp.MustCompile(`(?i)(^|[/._ -])(al|applovin|app-lovin|is|iron.?source|un|unity|unityads|unity-ads)([/._ -]|$)`)
|
||||
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
|
||||
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
|
||||
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
|
||||
@@ -232,9 +298,30 @@ var (
|
||||
|
||||
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
|
||||
|
||||
func isSupportedMraidBuild(filePath string) bool {
|
||||
normalized := strings.ReplaceAll(strings.ToLower(filePath), `\`, `/`)
|
||||
return mraidBuildNameRx.MatchString(normalized)
|
||||
func isInSupportedMraidFolderPath(filePath string) bool {
|
||||
supported := map[string]bool{
|
||||
"al": true,
|
||||
"applovin": true,
|
||||
"app-lovin": true,
|
||||
"is": true,
|
||||
"ironsource": true,
|
||||
"iron-source": true,
|
||||
"iron_source": true,
|
||||
"un": true,
|
||||
"unity": true,
|
||||
"unityads": true,
|
||||
"unity-ads": true,
|
||||
"unity_ads": true,
|
||||
}
|
||||
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
|
||||
return r == '/' || r == '\\'
|
||||
})
|
||||
for _, part := range parts {
|
||||
if supported[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkMraid(htmlSrc string) []mraidIssue {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type PlecConfig struct {
|
||||
@@ -114,12 +115,12 @@ func SaveConfig(cfg AppConfig) error {
|
||||
|
||||
func OpenInBrowser(url string) {
|
||||
// `start` is a cmd builtin. Quoted empty arg is the window title.
|
||||
cmd := exec.Command("cmd", "/c", "start", "", url)
|
||||
cmd := hiddenCommand("cmd", "/c", "start", "", url)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
func CopyToClipboard(text string) error {
|
||||
cmd := exec.Command("clip")
|
||||
cmd := hiddenCommand("clip")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -131,6 +132,15 @@ type FileFilter struct {
|
||||
|
||||
func psEscape(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
|
||||
func hiddenCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func PickFiles(title string, filters []FileFilter, multi bool, initialDir string) []string {
|
||||
filterStr := "All files|*.*"
|
||||
if len(filters) > 0 {
|
||||
@@ -159,7 +169,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$dlg.FileNames | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), multiStr, psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -198,7 +208,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.FileName
|
||||
}
|
||||
`, psEscape(title), psEscape(filterStr), psEscape(defaultName), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -215,7 +225,7 @@ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
Write-Output $dlg.SelectedPath
|
||||
}
|
||||
`, psEscape(title), psEscape(initialDir), psEscape(initialDir))
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
out, err := hiddenCommand("powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type playworksConvertOptions struct {
|
||||
SourcePath string `json:"sourcePath"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
BaseName string `json:"baseName"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
@@ -26,55 +27,52 @@ type playworksNetworkResult struct {
|
||||
|
||||
func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Playworks Converter</h2>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">Playworks Converter</h2>
|
||||
<p class="tool-description">Convert a Playworks HTML export into network-specific playable outputs.</p>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.section { margin-bottom:14px; }
|
||||
.section-label { font-size:11px; text-transform:uppercase; letter-spacing:0.5px; opacity:0.65; margin-bottom:5px; }
|
||||
.row { display:flex; gap:6px; align-items:center; }
|
||||
.net-grid { display:flex; flex-direction:column; gap:4px; }
|
||||
.net-row { display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer; user-select:none; }
|
||||
.net-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:4px 12px; }
|
||||
.net-row { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; user-select:none; border:1px solid #333; border-radius:4px; background:rgba(128,128,128,0.06); }
|
||||
.net-label { min-width:100px; font-weight:500; }
|
||||
.net-tag { font-size:10px; opacity:0.6; font-weight:400; }
|
||||
.net-note { font-size:11px; opacity:0.6; }
|
||||
.toggle-row { display:flex; gap:8px; margin-bottom:6px; }
|
||||
.toggle-row button { font-size:11px; padding:2px 8px; }
|
||||
.actions { display:flex; gap:10px; align-items:center; margin-top:16px; }
|
||||
#status { margin-top:12px; font-size:12px; opacity:0.75; min-height:16px; }
|
||||
.results { margin-top:10px; display:flex; flex-direction:column; gap:3px; }
|
||||
.res-row { display:flex; align-items:center; gap:8px; font-size:12px; font-family:Consolas, monospace; }
|
||||
.mark { font-weight:bold; width:14px; flex-shrink:0; }
|
||||
.ok { color:#3fb950; }
|
||||
.bad { color:#f85149; }
|
||||
.res-net { min-width:70px; font-weight:500; }
|
||||
.res-file { opacity:0.65; word-break:break-all; }
|
||||
.res-err { color:#f85149; opacity:0.9; }
|
||||
hr { border:none; border-top:1px solid #333; margin:14px 0; }
|
||||
</style>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Source HTML (Playworks export)</div>
|
||||
<div class="row">
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Source HTML (Playworks export)</div>
|
||||
<div class="field-row">
|
||||
<input id="src" type="text" placeholder="Path to Playworks HTML..." readonly style="flex:1;" />
|
||||
<button id="pickSrc">Browse...</button>
|
||||
<button id="pickSrc" class="secondary">Select File</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Output Folder</div>
|
||||
<div class="row">
|
||||
<div class="control-group">
|
||||
<div class="control-label">Base Filename</div>
|
||||
<div class="field-row">
|
||||
<input id="baseName" type="text" placeholder="Base filename..." style="flex:1;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="control-label">Output Folder</div>
|
||||
<div class="field-row">
|
||||
<input id="outDir" type="text" placeholder="Output folder..." style="flex:1;" />
|
||||
<button id="pickOut">Browse...</button>
|
||||
<button id="pickOut" class="secondary">Select Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">Networks to generate</div>
|
||||
<div class="control-group">
|
||||
<div class="control-label">Networks to generate</div>
|
||||
<div class="toggle-row">
|
||||
<button id="selectAll">All</button>
|
||||
<button id="selectNone">None</button>
|
||||
<button id="selectAll" class="secondary">All</button>
|
||||
<button id="selectNone" class="secondary">None</button>
|
||||
</div>
|
||||
<div class="net-grid">
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="al" checked /><span class="net-label">Applovin <span class="net-tag">al</span></span><span class="net-note">HTML + mraid.js in head</span></label>
|
||||
@@ -85,22 +83,25 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" checked /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="action-row">
|
||||
<button id="convert" disabled>Convert</button>
|
||||
<button id="openOut" class="secondary" style="display:none">Open Output Folder</button>
|
||||
<button id="openOut" class="secondary" disabled>Open Output Folder</button>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
<div class="results" id="results"></div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="results" class="results-panel"></div>
|
||||
|
||||
<script>
|
||||
let outputDir = '';
|
||||
const srcEl = document.getElementById('src');
|
||||
const outEl = document.getElementById('outDir');
|
||||
const baseNameEl = document.getElementById('baseName');
|
||||
const convertBtn = document.getElementById('convert');
|
||||
const openOutBtn = document.getElementById('openOut');
|
||||
const statusEl = document.getElementById('status');
|
||||
@@ -108,7 +109,7 @@ const resultsEl = document.getElementById('results');
|
||||
|
||||
function updateConvertBtn() {
|
||||
const hasNet = [...document.querySelectorAll('.net-cb')].some(c => c.checked);
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !hasNet;
|
||||
convertBtn.disabled = !srcEl.value || !outEl.value || !baseNameEl.value.trim() || !hasNet;
|
||||
}
|
||||
|
||||
document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||
@@ -116,6 +117,7 @@ document.getElementById('pickSrc').addEventListener('click', async () => {
|
||||
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();
|
||||
}
|
||||
@@ -126,7 +128,7 @@ document.getElementById('pickOut').addEventListener('click', async () => {
|
||||
if (j.path) { outEl.value = j.path; outputDir = j.path; updateConvertBtn(); }
|
||||
});
|
||||
document.getElementById('selectAll').addEventListener('click', () => {
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = true);
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.checked = c.dataset.tag !== 'tt');
|
||||
updateConvertBtn();
|
||||
});
|
||||
document.getElementById('selectNone').addEventListener('click', () => {
|
||||
@@ -135,20 +137,26 @@ document.getElementById('selectNone').addEventListener('click', () => {
|
||||
});
|
||||
document.querySelectorAll('.net-cb').forEach(c => c.addEventListener('change', updateConvertBtn));
|
||||
outEl.addEventListener('input', updateConvertBtn);
|
||||
baseNameEl.addEventListener('input', updateConvertBtn);
|
||||
openOutBtn.addEventListener('click', () => {
|
||||
if (outputDir) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: outputDir }) });
|
||||
});
|
||||
|
||||
function outputDisplayName(file) {
|
||||
const parts = file.split(/[\\\\/]/).filter(Boolean);
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
|
||||
document.getElementById('convert').addEventListener('click', async () => {
|
||||
const networks = [...document.querySelectorAll('.net-cb')].filter(c => c.checked).map(c => c.dataset.tag);
|
||||
statusEl.textContent = 'Converting...';
|
||||
resultsEl.innerHTML = '';
|
||||
openOutBtn.style.display = 'none';
|
||||
openOutBtn.disabled = true;
|
||||
convertBtn.disabled = true;
|
||||
const res = await fetch('/api/playworks/convert', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, networks })
|
||||
body: JSON.stringify({ sourcePath: srcEl.value, outputDir: outEl.value, baseName: baseNameEl.value, networks })
|
||||
});
|
||||
const msg = await res.json();
|
||||
convertBtn.disabled = false;
|
||||
@@ -157,28 +165,18 @@ document.getElementById('convert').addEventListener('click', async () => {
|
||||
const results = msg.results || [];
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
statusEl.textContent = ok + ' of ' + results.length + ' network(s) converted.';
|
||||
resultsEl.innerHTML = '';
|
||||
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>';
|
||||
const tbody = resultsEl.querySelector('tbody');
|
||||
for (const r of results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'res-row';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (r.ok ? 'ok' : 'bad');
|
||||
mark.textContent = r.ok ? '✓' : '✗';
|
||||
const net = document.createElement('span');
|
||||
net.className = 'res-net';
|
||||
net.textContent = r.network;
|
||||
const detail = document.createElement('span');
|
||||
if (r.ok) {
|
||||
detail.className = 'res-file';
|
||||
detail.textContent = r.file.split(/[\\\\/]/).pop();
|
||||
} else {
|
||||
detail.className = 'res-err';
|
||||
detail.textContent = r.error || 'Unknown error';
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td class="mono">' + r.network + '</td>' +
|
||||
'<td>' + (r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Error</span>') + '</td>' +
|
||||
'<td class="mono wrap">' + (r.ok ? outputDisplayName(r.file) : '<span class="muted">-</span>') + '</td>' +
|
||||
'<td class="err wrap">' + (r.ok ? '' : (r.error || 'Unknown error')) + '</td>';
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
row.append(mark, net, detail);
|
||||
resultsEl.appendChild(row);
|
||||
}
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.style.display = ''; }
|
||||
if (ok > 0) { outputDir = outEl.value; openOutBtn.disabled = false; }
|
||||
});
|
||||
</script>
|
||||
`
|
||||
@@ -192,7 +190,7 @@ func PlayworksPickSource(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0])})
|
||||
writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0]), "baseName": playworksSourceBaseName(picked[0])})
|
||||
}
|
||||
|
||||
func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -238,26 +236,77 @@ func playworksSourceBaseName(sourcePath string) string {
|
||||
return playworksSuffixRx.ReplaceAllString(base, "")
|
||||
}
|
||||
|
||||
func playworksOutputBaseName(opts playworksConvertOptions) string {
|
||||
baseName := strings.TrimSpace(opts.BaseName)
|
||||
if baseName == "" {
|
||||
baseName = playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||
baseName = filepath.Base(baseName)
|
||||
baseName = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '<', '>', ':', '"', '/', '\\', '|', '?', '*':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, baseName)
|
||||
baseName = strings.TrimSpace(baseName)
|
||||
if baseName == "" {
|
||||
return playworksSourceBaseName(opts.SourcePath)
|
||||
}
|
||||
return baseName
|
||||
}
|
||||
|
||||
func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) {
|
||||
transformed := transformPlayworksHTML(htmlSrc, network)
|
||||
baseName := playworksSourceBaseName(opts.SourcePath)
|
||||
baseName := playworksOutputBaseName(opts)
|
||||
htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network)
|
||||
outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network))
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if playworksNeedsZip(network) {
|
||||
zipPath := filepath.Join(opts.OutputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||
zipPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.zip", baseName, network))
|
||||
if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return zipPath, nil
|
||||
}
|
||||
|
||||
outPath := filepath.Join(opts.OutputDir, htmlFileName)
|
||||
outPath := filepath.Join(outputDir, htmlFileName)
|
||||
if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func playworksNetworkOutputFolder(network string) string {
|
||||
switch network {
|
||||
case "al":
|
||||
return "Applovin"
|
||||
case "is":
|
||||
return "Ironsource"
|
||||
case "un":
|
||||
return "Unity"
|
||||
case "fb":
|
||||
return "Facebook"
|
||||
case "gg":
|
||||
return "GoogleAds"
|
||||
case "mo":
|
||||
return "Moloco"
|
||||
case "vu":
|
||||
return "Vungle"
|
||||
case "mtg":
|
||||
return "Mintegral"
|
||||
case "tt":
|
||||
return "TikTok"
|
||||
default:
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
func playworksNeedsZip(network string) bool {
|
||||
return network == "gg" || network == "vu" || network == "mtg"
|
||||
}
|
||||
|
||||
@@ -17,42 +17,54 @@ import (
|
||||
|
||||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>PLEC Upload</h2>
|
||||
<p class="hint" style="margin-top:0;">Pick HTML files, give each an iteration name, then upload. The preview link is returned by the server.</p>
|
||||
<header class="tool-header">
|
||||
<h2 class="tool-title">PLEC Upload</h2>
|
||||
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||||
</header>
|
||||
|
||||
<table id="rows" style="width:100%;border-collapse:collapse;margin-bottom:12px;">
|
||||
<thead><tr>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">HTML File</th>
|
||||
<th style="width:45%;text-align:left;padding:6px 8px;font-size:12px;text-transform:uppercase;opacity:0.7;">Iteration Name</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<section class="tool-panel">
|
||||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||||
<div class="panel-body">
|
||||
<div class="control-group">
|
||||
<div class="file-row">
|
||||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||||
<button id="pickFiles" class="secondary">Select File(s)</button>
|
||||
<button id="clear" class="secondary">Clear</button>
|
||||
</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;">
|
||||
<table id="rows" class="data-table">
|
||||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="addRow" class="secondary">+ Add Row</button>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="upload">Upload</button>
|
||||
</div>
|
||||
|
||||
<div id="status" style="margin-top:12px;min-height:20px;"></div>
|
||||
<div id="results"></div>
|
||||
<button id="openResult" class="secondary" disabled>Open</button>
|
||||
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||||
</div>
|
||||
<div id="status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#rows td { padding:6px 8px; vertical-align:middle; border-bottom:1px solid #333; }
|
||||
.file-cell { display:flex; align-items:center; gap:8px; }
|
||||
.file-name { opacity:0.85; font-size:12px; word-break:break-all; }
|
||||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||||
.result { margin-top:12px; padding:10px; background:#252526; border-left:3px solid #007fd4; word-break:break-all; }
|
||||
.result a { color:#9cdcfe; }
|
||||
.result-actions { margin-top:8px; display:flex; gap:8px; }
|
||||
.remove-btn { background:transparent; color:#ddd; padding:4px 8px; }
|
||||
#rows input[type=text] { width: 100%; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const PAGE_SIZE = 5;
|
||||
let nextId = 1;
|
||||
let rows = [];
|
||||
let page = 0;
|
||||
let previewUrl = '';
|
||||
const tbody = document.querySelector('#rows tbody');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const rowsPanel = document.getElementById('rowsPanel');
|
||||
const emptySelection = document.getElementById('emptySelection');
|
||||
const openResultBtn = document.getElementById('openResult');
|
||||
const copyResultBtn = document.getElementById('copyResult');
|
||||
|
||||
function parseIterationName(filename) {
|
||||
const base = filename.replace(/\.html?$/i, '');
|
||||
@@ -63,65 +75,101 @@ function parseIterationName(filename) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const id = 'r' + (nextId++);
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.id = id;
|
||||
tr.innerHTML = ` + "`" + `
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<button class="pick secondary">Choose...</button>
|
||||
<span class="file-name" data-name>(no file)</span>
|
||||
</div>
|
||||
<input type="hidden" data-path />
|
||||
<div class="row-error" data-error></div>
|
||||
</td>
|
||||
<td><input type="text" data-iter placeholder="iteration name" style="width:100%;" /></td>
|
||||
<td><button class="remove-btn" title="Remove">×</button></td>
|
||||
` + "`" + `;
|
||||
tr.querySelector('.pick').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
if (j.files && j.files.length) {
|
||||
const affected = [];
|
||||
j.files.forEach(function(f, i) {
|
||||
const target = i === 0 ? tr : (addRow(), tbody.lastElementChild);
|
||||
target.querySelector('[data-path]').value = f.path;
|
||||
target.querySelector('[data-name]').textContent = f.name;
|
||||
const iter = target.querySelector('[data-iter]');
|
||||
if (!iter.value) iter.value = parseIterationName(f.name);
|
||||
affected.push(target);
|
||||
});
|
||||
if (affected.length > 1) {
|
||||
const iters = affected.map(r => r.querySelector('[data-iter]').value);
|
||||
if (iters[0] !== '' && iters.every(n => n === iters[0])) {
|
||||
affected.forEach((r, i) => {
|
||||
r.querySelector('[data-iter]').value = String(i + 1).padStart(2, '0') + '_' + iters[0];
|
||||
function escapeHtml(s){
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function addFiles(files) {
|
||||
const existing = new Set(rows.map(r => r.path));
|
||||
const before = rows.length;
|
||||
(files || []).forEach(f => {
|
||||
if (existing.has(f.path)) return;
|
||||
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
|
||||
existing.add(f.path);
|
||||
});
|
||||
const added = rows.length - before;
|
||||
if (added > 1) {
|
||||
const batch = rows.slice(rows.length - added);
|
||||
const first = batch[0].iteration;
|
||||
if (first && batch.every(r => r.iteration === first)) {
|
||||
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
|
||||
}
|
||||
}
|
||||
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
renderRows();
|
||||
}
|
||||
function renderRows() {
|
||||
const oldPager = document.getElementById('selectedPager');
|
||||
if (oldPager) oldPager.remove();
|
||||
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||||
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||||
if (!rows.length) { tbody.innerHTML = ''; return; }
|
||||
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||||
page = Math.min(page, maxPage);
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||||
tbody.innerHTML = pageRows.map((row, offset) => {
|
||||
const i = start + offset;
|
||||
return '<tr data-id="' + row.id + '">' +
|
||||
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
|
||||
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
|
||||
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||||
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
|
||||
});
|
||||
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||||
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
|
||||
});
|
||||
if (rows.length > PAGE_SIZE) {
|
||||
const pager = document.createElement('div');
|
||||
pager.id = 'selectedPager';
|
||||
pager.className = 'pager';
|
||||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||||
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||||
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||||
rowsPanel.after(pager);
|
||||
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||||
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||||
}
|
||||
});
|
||||
tr.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
tr.remove();
|
||||
if (!tbody.children.length) addRow();
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
function setPreview(url) {
|
||||
previewUrl = url || '';
|
||||
openResultBtn.disabled = !previewUrl;
|
||||
copyResultBtn.disabled = !previewUrl;
|
||||
}
|
||||
|
||||
document.getElementById('addRow').addEventListener('click', addRow);
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||||
const j = await res.json();
|
||||
addFiles(j.files || []);
|
||||
});
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
rows = [];
|
||||
page = 0;
|
||||
setPreview('');
|
||||
statusEl.textContent = '';
|
||||
renderRows();
|
||||
});
|
||||
openResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
|
||||
});
|
||||
copyResultBtn.addEventListener('click', () => {
|
||||
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
|
||||
});
|
||||
|
||||
document.getElementById('upload').addEventListener('click', async () => {
|
||||
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
|
||||
rows.forEach(row => row.error = '');
|
||||
renderRows();
|
||||
statusEl.textContent = '';
|
||||
resultsEl.innerHTML = '';
|
||||
const rows = [...tbody.querySelectorAll('tr')].map(tr => ({
|
||||
id: tr.dataset.id,
|
||||
path: tr.querySelector('[data-path]').value,
|
||||
iteration: tr.querySelector('[data-iter]').value,
|
||||
})).filter(r => r.path || r.iteration);
|
||||
setPreview('');
|
||||
if (rows.length === 0) {
|
||||
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
|
||||
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'Uploading...';
|
||||
@@ -133,9 +181,10 @@ document.getElementById('upload').addEventListener('click', async () => {
|
||||
const j = await res.json();
|
||||
if (j.rowErrors) {
|
||||
for (const re of j.rowErrors) {
|
||||
const tr = tbody.querySelector('tr[data-id="' + re.id + '"]');
|
||||
if (tr) tr.querySelector('[data-error]').textContent = re.message;
|
||||
const row = rows.find(r => r.id === re.id);
|
||||
if (row) row.error = re.message;
|
||||
}
|
||||
renderRows();
|
||||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||||
return;
|
||||
}
|
||||
@@ -144,40 +193,10 @@ document.getElementById('upload').addEventListener('click', async () => {
|
||||
return;
|
||||
}
|
||||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'result';
|
||||
if (j.preview) {
|
||||
wrap.innerHTML = '<div><strong>Preview:</strong> <a href="' + j.preview + '" target="_blank">' + j.preview + '</a></div>';
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'result-actions';
|
||||
const copy = document.createElement('button');
|
||||
copy.textContent = 'Copy';
|
||||
copy.onclick = () => fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: j.preview }) });
|
||||
const open = document.createElement('button');
|
||||
open.textContent = 'Open';
|
||||
open.className = 'secondary';
|
||||
open.onclick = () => fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: j.preview }) });
|
||||
const showRaw = document.createElement('button');
|
||||
showRaw.textContent = 'Show server response';
|
||||
showRaw.className = 'secondary';
|
||||
showRaw.onclick = () => {
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.marginTop = '8px';
|
||||
pre.style.whiteSpace = 'pre-wrap';
|
||||
pre.textContent = j.rawText;
|
||||
wrap.appendChild(pre);
|
||||
showRaw.disabled = true;
|
||||
};
|
||||
actions.appendChild(copy); actions.appendChild(open); actions.appendChild(showRaw);
|
||||
wrap.appendChild(actions);
|
||||
} else {
|
||||
wrap.innerHTML = '<div>No preview field in response. Raw:</div><pre>' + (j.rawText || '').replace(/</g,'<') + '</pre>';
|
||||
}
|
||||
resultsEl.innerHTML = '';
|
||||
resultsEl.appendChild(wrap);
|
||||
setPreview(j.preview);
|
||||
});
|
||||
|
||||
addRow();
|
||||
renderRows();
|
||||
</script>
|
||||
`
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -204,6 +223,23 @@ func PlecPick(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
func PlecPickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := LoadConfig()
|
||||
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||||
if dir == "" {
|
||||
writeJSON(w, map[string]any{"files": []any{}})
|
||||
return
|
||||
}
|
||||
cfg.LastPickDir = dir
|
||||
_ = SaveConfig(cfg)
|
||||
paths := collectHTMLFiles(dir)
|
||||
files := make([]map[string]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"files": files})
|
||||
}
|
||||
|
||||
type plecRow struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
|
||||
Reference in New Issue
Block a user