Files
vsix-hpl-toolbox/src/tools/plecUpload.ts
2026-05-23 16:57:24 +08:00

377 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: true,
filters: { HTML: ['html', 'htm'] },
openLabel: 'Select',
defaultUri: getPickerDefaultUri(),
});
if (picked && picked.length) {
panel.webview.postMessage({
type: 'filesSelected',
rowId: msg.rowId,
files: picked.map(u => ({ path: u.fsPath, name: path.basename(u.fsPath) })),
});
}
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 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;
}
const stamp = timestampSuffix();
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
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], stampedNames[i], { 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 });
}
function timestampSuffix(d: Date = new Date()): string {
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return (
d.getFullYear().toString() +
pad(d.getMonth() + 1) +
pad(d.getDate()) +
pad(d.getHours()) +
pad(d.getMinutes()) +
pad(d.getSeconds())
);
}
function appendTimestamp(filename: string, stamp: string): string {
const ext = path.extname(filename);
const base = ext ? filename.slice(0, -ext.length) : filename;
return `${base}_${stamp}${ext}`;
}
function getPickerDefaultUri(): vscode.Uri | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return undefined;
const root = folders[0].uri.fsPath;
const dist = path.join(root, 'dist');
if (fs.existsSync(dist) && fs.statSync(dist).isDirectory()) {
return vscode.Uri.file(dist);
}
return vscode.Uri.file(root);
}
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');
function parseIterationName(filename) {
const base = filename.replace(/\\.html?$/i, '');
const tokens = base.split('_');
for (const t of tokens) {
if (/^(full|\\d+clk|\\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
}
return base;
}
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 === '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];
});
}
}
} 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>`;
}