first commit

This commit is contained in:
HPL-JesusCastro
2026-05-07 20:53:31 +08:00
commit def3dfb60d
17 changed files with 1413 additions and 0 deletions

18
src/extension.ts Normal file
View File

@@ -0,0 +1,18 @@
import * as vscode from 'vscode';
import { LauncherViewProvider } from './launcherView';
import { openPlecUpload } from './tools/plecUpload';
import { openApplovinUpload } from './tools/applovinUpload';
import { openBase64Scanner } from './tools/base64Scanner';
import { openDailyUpdate } from './tools/dailyUpdate';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('hplToolbox.openPlecUpload', () => openPlecUpload(context)),
vscode.commands.registerCommand('hplToolbox.openApplovinUpload', () => openApplovinUpload(context)),
vscode.commands.registerCommand('hplToolbox.openBase64Scanner', () => openBase64Scanner(context)),
vscode.commands.registerCommand('hplToolbox.openDailyUpdate', () => openDailyUpdate(context)),
vscode.window.registerWebviewViewProvider('hplToolbox.launcher', new LauncherViewProvider())
);
}
export function deactivate() {}

65
src/launcherView.ts Normal file
View File

@@ -0,0 +1,65 @@
import * as vscode from 'vscode';
export class LauncherViewProvider implements vscode.WebviewViewProvider {
resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true };
view.webview.html = getHtml();
view.webview.onDidReceiveMessage((msg) => {
if (msg?.type === 'open' && typeof msg.command === 'string') {
vscode.commands.executeCommand(msg.command);
}
});
}
}
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: 12px 8px; }
h3 { margin: 0 0 12px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.7; }
.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;
}
.tool-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.tool-desc { display: block; font-size: 11px; opacity: 0.7; margin-top: 2px; }
</style>
</head>
<body>
<h3>HPL Toolbox</h3>
<button class="tool-btn" data-cmd="hplToolbox.openPlecUpload">
PLEC Upload
<span class="tool-desc">Upload HTML to internal PLEC server</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openApplovinUpload">
AppLovin Demo Upload
<span class="tool-desc">Upload to p.applov.in (QR preview)</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openBase64Scanner">
Base64 Scanner
<span class="tool-desc">Find non-base64 assets in HTML</span>
</button>
<button class="tool-btn" data-cmd="hplToolbox.openDailyUpdate">
Daily Update
<span class="tool-desc">Compose &amp; copy a daily status</span>
</button>
<script>
const vscode = acquireVsCodeApi();
document.querySelectorAll('.tool-btn').forEach((btn) => {
btn.addEventListener('click', () => {
vscode.postMessage({ type: 'open', command: btn.dataset.cmd });
});
});
</script>
</body>
</html>`;
}

262
src/tools/applovinUpload.ts Normal file
View File

@@ -0,0 +1,262 @@
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';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openApplovinUpload(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.applovinUpload', 'AppLovin Demo Upload');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
});
if (picked && picked.length) {
panel.webview.postMessage({
type: 'filesSelected',
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
});
}
return;
}
if (msg.type === 'upload') {
await handleUpload(panel, msg.paths);
return;
}
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
}
});
}
async function handleUpload(panel: vscode.WebviewPanel, filePaths: string[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.applovin');
const cookie = (cfg.get<string>('cookie') || '').trim();
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
if (!paths.length) {
panel.webview.postMessage({ type: 'error', message: 'No files selected.' });
return;
}
const headers: Record<string, string> = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Origin': 'https://p.applov.in',
'Referer': 'https://p.applov.in/playablePreview?create=1&qr=1',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
};
if (cookie) headers['Cookie'] = `__Host-APPLOVINID=${cookie}`;
const buildForm = (filePath: string): FormData => {
const buf = fs.readFileSync(filePath);
const file = new File([buf], path.basename(filePath), { type: 'text/html' });
const form = new FormData();
form.append('file', file as any);
return form;
};
panel.webview.postMessage({ type: 'start', total: paths.length });
for (let i = 0; i < paths.length; i++) {
const filePath = paths[i];
const fileName = path.basename(filePath);
const idx = i + 1;
const reportError = (message: string) => {
panel.webview.postMessage({ type: 'fileError', name: fileName, message });
};
if (!filePath || !fs.existsSync(filePath)) { reportError('File missing'); continue; }
if (!/\.html?$/i.test(filePath)) { reportError('Must be .html'); continue; }
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Validating ${fileName}...` });
let res: Response;
try {
res = await fetch('https://p.applov.in/validateHTMLContent', {
method: 'POST', body: buildForm(filePath), headers,
});
} catch (err: any) { reportError('Network error (validate): ' + (err?.message || err)); continue; }
const validateText = await res.text();
if (!res.ok) { reportError(`Validate HTTP ${res.status}: ${validateText.slice(0, 300)}`); continue; }
let validateJson: any;
try { validateJson = JSON.parse(validateText); } catch { validateJson = null; }
if (validateJson && validateJson.code && validateJson.code !== 200) {
reportError(`Validation failed: ${validateText.slice(0, 300)}`); continue;
}
panel.webview.postMessage({ type: 'status', message: `[${idx}/${paths.length}] Uploading ${fileName}...` });
try {
res = await fetch('https://p.applov.in/getCachedAdURL', {
method: 'POST', body: buildForm(filePath), headers,
});
} catch (err: any) { reportError('Network error (cache): ' + (err?.message || err)); continue; }
const cacheText = await res.text();
if (!res.ok) { reportError(`Cache HTTP ${res.status}: ${cacheText.slice(0, 300)}`); continue; }
let cacheJson: any;
try { cacheJson = JSON.parse(cacheText); }
catch { reportError(`Non-JSON response: ${cacheText.slice(0, 300)}`); continue; }
const hash = cacheJson.results;
if (!hash) { reportError(`No hash in response: ${cacheText.slice(0, 300)}`); continue; }
panel.webview.postMessage({
type: 'fileResult',
name: fileName,
hash,
previewUrl: `https://p.applov.in/getPreviewHTML?preview=${hash}`,
pageUrl: `https://p.applov.in/playablePreview?preview=${hash}&qr=1`,
qrImg: `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(hash)}`,
});
}
panel.webview.postMessage({ type: 'done' });
}
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; }
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; }
.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;
}
.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; }
</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). Requires the <code>__Host-APPLOVINID</code> cookie set in <em>HPL Toolbox &mdash; AppLovin Upload</em> settings.</p>
<div class="file-cell" style="margin-bottom:8px;">
<button id="pick" class="secondary">Choose files...</button>
<span class="file-name" id="fileName">(no files)</span>
</div>
<div class="actions">
<button id="upload">Upload to AppLovin</button>
</div>
<div id="status"></div>
<div id="results"></div>
<script>
const vscode = acquireVsCodeApi();
const pickBtn = document.getElementById('pick');
const uploadBtn = document.getElementById('upload');
const fileNameEl = document.getElementById('fileName');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
let selectedPaths = [];
pickBtn.addEventListener('click', () => vscode.postMessage({ type: 'pickFiles' }));
uploadBtn.addEventListener('click', () => {
statusEl.textContent = '';
resultsEl.innerHTML = '';
if (!selectedPaths.length) {
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
return;
}
uploadBtn.disabled = true;
vscode.postMessage({ type: 'upload', paths: selectedPaths });
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'filesSelected') {
selectedPaths = m.files.map(f => f.path);
const names = m.files.map(f => f.name);
fileNameEl.textContent = names.length === 1
? names[0]
: names.length + ' files: ' + names.slice(0, 3).join(', ') + (names.length > 3 ? '...' : '');
} else if (m.type === 'start') {
resultsEl.innerHTML = '';
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';
} else if (m.type === 'error') {
const div = document.createElement('div');
div.className = 'err';
div.textContent = m.message;
statusEl.innerHTML = '';
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);
} 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 + '" 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 actions = document.createElement('div');
actions.className = 'result-actions';
const copyHash = document.createElement('button');
copyHash.textContent = 'Copy Hash';
copyHash.onclick = () => vscode.postMessage({ type: 'copy', text: m.hash });
const copyPreview = document.createElement('button');
copyPreview.textContent = 'Copy URL';
copyPreview.className = 'secondary';
copyPreview.onclick = () => vscode.postMessage({ type: 'copy', text: m.previewUrl });
const openPreview = document.createElement('button');
openPreview.textContent = 'Open';
openPreview.className = 'secondary';
openPreview.onclick = () => vscode.postMessage({ type: 'open', text: m.previewUrl });
actions.appendChild(copyHash);
actions.appendChild(copyPreview);
actions.appendChild(openPreview);
wrap.appendChild(actions);
resultsEl.appendChild(wrap);
} else if (m.type === 'done') {
statusEl.innerHTML = '<span class="ok">Done.</span>';
uploadBtn.disabled = false;
}
});
</script>
</body>
</html>`;
}

192
src/tools/base64Scanner.ts Normal file
View File

@@ -0,0 +1,192 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { 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');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'ready') {
const ws = vscode.workspace.workspaceFolders?.[0];
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 });
}
}
} else if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
});
if (picked && picked[0]) {
panel.webview.postMessage({ type: 'folderPicked', path: picked[0].fsPath });
}
} else if (msg.type === 'scan') {
const folder: string = msg.folder;
if (!folder || !fs.existsSync(folder)) {
panel.webview.postMessage({ type: 'results', results: [], error: 'Invalid folder' });
return;
}
const htmlFiles = await collectHtmlFiles(folder);
const results: { file: string; ok: boolean; assets: string[] }[] = [];
for (const file of htmlFiles) {
try {
const content = fs.readFileSync(file, 'utf8');
const offenders = findNonBase64Assets(content);
results.push({ file: path.relative(folder, file), ok: offenders.length === 0, assets: offenders });
} catch {
// skip unreadable
}
}
panel.webview.postMessage({ type: 'results', results });
}
});
}
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 findNonBase64Assets(html: string): string[] {
const markup = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
const offenders = new Set<string>();
const attrRegex = /\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')/gi;
let m: RegExpExecArray | null;
while ((m = attrRegex.exec(markup)) !== null) {
const url = (m[2] ?? m[3] ?? '').trim();
if (!url) continue;
if (isAssetReference(url) && !isBase64DataUri(url)) {
offenders.add(url);
}
}
const cssUrlRegex = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
while ((m = cssUrlRegex.exec(markup)) !== null) {
const url = (m[1] ?? m[2] ?? m[3] ?? '').trim();
if (!url) continue;
if (!isBase64DataUri(url)) {
offenders.add(url);
}
}
return [...offenders];
}
function isBase64DataUri(url: string): boolean {
return /^data:[^;,]*;base64,/i.test(url);
}
function isAssetReference(url: string): boolean {
if (url.startsWith('#') || url.toLowerCase().startsWith('javascript:')) return false;
if (/^(mailto:|tel:)/i.test(url)) return false;
return true;
}
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<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; }
</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...</button>
<button id="scan">Scan</button>
</div>
<div id="status"></div>
<div id="results"></div>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('scan').addEventListener('click', () => {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder first.'; return; }
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
vscode.postMessage({ type: 'scan', folder });
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
} else if (msg.type === 'results') {
if (msg.error) { statusEl.textContent = 'Error: ' + msg.error; 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>';
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);
}
resultsEl.appendChild(row);
}
}
});
</script>
</body>
</html>`;
}

121
src/tools/dailyUpdate.ts Normal file
View File

@@ -0,0 +1,121 @@
import * as vscode from 'vscode';
import { singletonPanel } from './shared';
const PROJECT_KEY = 'hplToolbox.dailyUpdate.lastProject';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openDailyUpdate(context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.dailyUpdate', 'Daily Update');
if (!isNew) return;
const lastProject = context.globalState.get<string>(PROJECT_KEY, '');
panel.webview.html = getHtml(lastProject);
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'submit') {
const text = formatMessage(msg.payload);
await vscode.env.clipboard.writeText(text);
await context.globalState.update(PROJECT_KEY, msg.payload.project);
vscode.window.showInformationMessage('Daily update copied to clipboard.');
panel.dispose();
}
});
}
function formatMessage(p: { date: string; status: string; project: string; remarks: string }): string {
const bullets = p.remarks
.split(/\r?\n/)
.map((l) => l.replace(/^\s*[-*]\s*/, '').trim())
.filter((l) => l.length > 0)
.map((l) => `- ${l}`)
.join('\n');
const lines = [
`Date: ${formatDate(p.date)}`,
`Status: ${p.status}`,
`Project: ${p.project}`,
`Remarks:`,
bullets,
];
if (p.status === 'For OT') {
lines.push('@Joyce Lanot , @Reland Pigte, @Anthony Castor, @Angela Grace');
}
return lines.join('\n');
}
function formatDate(iso: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return iso;
return `${m[2]}/${m[3]}/${m[1]}`;
}
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getHtml(lastProject: string): string {
const today = new Date().toISOString().slice(0, 10);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px; max-width: 520px; }
label { display: block; margin-top: 12px; font-size: 12px; opacity: 0.85; }
input, select, textarea {
width: 100%; box-sizing: border-box; margin-top: 4px; padding: 6px 8px;
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border, transparent); font-family: inherit; font-size: 13px;
}
textarea { min-height: 96px; resize: vertical; }
button {
margin-top: 16px; padding: 8px 16px; cursor: pointer;
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
border: none; font-size: 13px;
}
button:hover { background: var(--vscode-button-hoverBackground); }
</style>
</head>
<body>
<h2 style="margin:0 0 4px 0;">Daily Update</h2>
<div style="font-size:12px;opacity:0.7;">Submit copies the formatted text to your clipboard.</div>
<label for="date">Date</label>
<input type="date" id="date" value="${today}" />
<label for="status">Status</label>
<select id="status">
<option>Started</option>
<option>Progress</option>
<option>Finished</option>
<option>For OT</option>
</select>
<label for="project">Project</label>
<input type="text" id="project" placeholder="Project name" value="${escapeAttr(lastProject)}" />
<label for="remarks">Remarks</label>
<textarea id="remarks" placeholder="What did you do?"></textarea>
<button id="submit">Submit</button>
<script>
const vscode = acquireVsCodeApi();
document.getElementById('submit').addEventListener('click', () => {
vscode.postMessage({
type: 'submit',
payload: {
date: document.getElementById('date').value,
status: document.getElementById('status').value,
project: document.getElementById('project').value.trim(),
remarks: document.getElementById('remarks').value.trim(),
},
});
});
</script>
</body>
</html>`;
}

347
src/tools/plecUpload.ts Normal file
View File

@@ -0,0 +1,347 @@
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';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openPlecUpload(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
try {
if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'pickFile') {
const picked = await vscode.window.showOpenDialog({
canSelectMany: false,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
});
if (picked && picked[0]) {
const fp = picked[0].fsPath;
panel.webview.postMessage({
type: 'fileSelected',
rowId: msg.rowId,
path: fp,
name: path.basename(fp),
});
}
return;
}
if (msg.type === 'upload') {
await handleUpload(panel, msg.rows);
return;
}
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: err?.message || String(err) });
}
});
}
interface UploadRow {
id: string;
path: string;
iteration: string;
}
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const uploadUrl = cfg.get<string>('uploadUrl')!;
const originUrl = cfg.get<string>('originUrl')!;
const previewBase = cfg.get<string>('previewBase')!;
const skipExistsCheck = cfg.get<boolean>('skipExistsCheck')!;
const valid: UploadRow[] = [];
for (const r of rows) {
if (!r.path || !fs.existsSync(r.path)) {
panel.webview.postMessage({ type: 'rowError', rowId: r.id, message: 'File missing' });
return;
}
if (!r.iteration || !r.iteration.trim()) {
panel.webview.postMessage({ type: 'rowError', rowId: r.id, message: 'Iteration name required' });
return;
}
if (!/\.html?$/i.test(r.path)) {
panel.webview.postMessage({ type: 'rowError', rowId: r.id, message: 'Must be .html' });
return;
}
valid.push(r);
}
if (valid.length === 0) {
panel.webview.postMessage({ type: 'error', message: 'Nothing to upload.' });
return;
}
panel.webview.postMessage({ type: 'status', message: 'Checking filename availability...' });
if (!skipExistsCheck) {
for (const r of valid) {
const fname = path.basename(r.path);
const exists = await checkFileExists(previewBase + encodeURIComponent(fname));
if (exists === true) {
panel.webview.postMessage({
type: 'rowError',
rowId: r.id,
message: `Filename "${fname}" already exists on preview server. Rename and retry.`,
});
return;
}
}
}
panel.webview.postMessage({ type: 'status', message: 'Uploading...' });
const form = new FormData();
for (let i = 0; i < valid.length; i++) {
const r = valid[i];
const idx = i + 1;
const buf = fs.readFileSync(r.path);
const file = new File([buf], path.basename(r.path), { type: 'text/html' });
form.append(`fileUpload_${idx}`, file as any);
form.append(`iterationNameUpload_${idx}`, encodeURI(r.iteration.replace(/\s+/g, '')));
}
let res: Response;
try {
res = await fetch(uploadUrl, {
method: 'POST',
body: form,
headers: {
'Origin': originUrl,
'Referer': originUrl + '/',
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*',
},
});
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: 'Network error: ' + (err?.message || err) });
return;
}
const text = await res.text();
if (!res.ok) {
panel.webview.postMessage({
type: 'error',
message: `HTTP ${res.status}\n${text.slice(0, 800)}`,
});
return;
}
let json: any;
try { json = JSON.parse(text); }
catch {
panel.webview.postMessage({
type: 'error',
message: `Server returned non-JSON:\n${text.slice(0, 800)}`,
});
return;
}
const preview = json.preview || json.previewURL || null;
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
}
async function checkFileExists(url: string): Promise<boolean | null> {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.status === 200) return true;
if (res.status === 404) return false;
return null;
} catch {
return null;
}
}
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; }
.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;
}
.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; }
</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>
<table id="rows">
<thead>
<tr>
<th style="width:45%">HTML File</th>
<th style="width:45%">Iteration Name</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status"></div>
<div id="results"></div>
<script>
const vscode = acquireVsCodeApi();
let nextId = 1;
const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
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);
}
document.getElementById('addRow').addEventListener('click', addRow);
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);
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Add at least one row with a file and iteration name.</span>';
return;
}
vscode.postMessage({ type: 'upload', rows });
});
function clearErrors() {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'fileSelected') {
const tr = tbody.querySelector('tr[data-id="' + m.rowId + '"]');
if (!tr) return;
tr.querySelector('[data-path]').value = m.path;
tr.querySelector('[data-name]').textContent = m.name;
const iter = tr.querySelector('[data-iter]');
if (!iter.value) iter.value = m.name.replace(/\\.html?$/i, '');
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = '';
} else if (m.type === 'error') {
statusEl.innerHTML = '';
const div = document.createElement('div');
div.className = 'err';
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;
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, '&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
}
});
addRow();
</script>
</body>
</html>`;
}

34
src/tools/shared.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as vscode from 'vscode';
export function handleClipboardAndOpen(msg: any): boolean {
if (msg?.type === 'copy' && typeof msg.text === 'string') {
vscode.env.clipboard.writeText(msg.text);
vscode.window.showInformationMessage('Copied to clipboard.');
return true;
}
if (msg?.type === 'open' && typeof msg.text === 'string') {
vscode.env.openExternal(vscode.Uri.parse(msg.text));
return true;
}
return false;
}
export function singletonPanel(
store: { panel: vscode.WebviewPanel | null },
viewType: string,
title: string
): { panel: vscode.WebviewPanel; isNew: boolean } {
if (store.panel) {
store.panel.reveal();
return { panel: store.panel, isNew: false };
}
const panel = vscode.window.createWebviewPanel(
viewType,
title,
vscode.ViewColumn.Active,
{ enableScripts: true, retainContextWhenHidden: true }
);
store.panel = panel;
panel.onDidDispose(() => { store.panel = null; });
return { panel, isNew: true };
}