451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`;
|
||
}
|