Files
vsix-hpl-toolbox/src/tools/plecUpload.ts

451 lines
15 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 { getToolWebviewStyles, 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 === '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,
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;
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${path.basename(r.path)}` });
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, '')));
}
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
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);
}
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>
${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; }
.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; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<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>
<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>
</div>
<div class="action-row">
<button id="upload">Upload</button>
<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, '');
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 openResultBtn = document.getElementById('openResult');
const copyResultBtn = document.getElementById('copyResult');
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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 = '';
statusEl.classList.remove('is-busy');
setPreview('');
if (rows.length === 0) {
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
return;
}
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
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() {
rows.forEach(row => row.error = '');
renderRows();
}
window.addEventListener('message', (e) => {
const m = e.data;
if (m.type === 'filesSelected') {
addFiles(m.files || []);
} else if (m.type === 'status') {
statusEl.textContent = m.message;
statusEl.className = 'status-panel is-busy';
} else if (m.type === 'error') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '';
const div = document.createElement('div');
div.className = 'err';
div.textContent = m.message;
statusEl.appendChild(div);
} else if (m.type === 'rowError') {
statusEl.classList.remove('is-busy');
const row = rows.find(r => r.id === m.rowId);
if (row) row.error = m.message;
renderRows();
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
} else if (m.type === 'result') {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
setPreview(m.preview);
}
});
renderRows();
</script>
</body>
</html>`;
}