Files
vsix-hpl-toolbox/standalone/plec.go
2026-05-23 16:57:24 +08:00

406 lines
13 KiB
Go
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.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
urlpkg "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
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>
<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>
<tbody></tbody>
</table>
<div style="display:flex;gap:8px;">
<button id="addRow" class="secondary">+ Add Row</button>
<button id="upload">Upload</button>
</div>
<div id="status" style="margin-top:12px;min-height:20px;"></div>
<div id="results"></div>
<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; }
</style>
<script>
let nextId = 1;
const tbody = document.querySelector('#rows tbody');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
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;
}
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];
});
}
}
}
});
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', async () => {
document.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
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;
}
statusEl.textContent = 'Uploading...';
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
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;
}
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
return;
}
if (j.error) {
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
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,'&lt;') + '</pre>';
}
resultsEl.innerHTML = '';
resultsEl.appendChild(wrap);
});
addRow();
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/plec", "PLEC Upload", body)))
}
func PlecPick(w http.ResponseWriter, r *http.Request) {
cfg := LoadConfig()
picked := PickFiles(
"Select HTML",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, cfg.LastPickDir,
)
if len(picked) == 0 {
writeJSON(w, map[string]any{"files": []any{}})
return
}
cfg.LastPickDir = filepath.Dir(picked[0])
_ = SaveConfig(cfg)
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})
}
type plecRow struct {
ID string `json:"id"`
Path string `json:"path"`
Iteration string `json:"iteration"`
}
func PlecUpload(w http.ResponseWriter, r *http.Request) {
var req struct {
Rows []plecRow `json:"rows"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
cfg := LoadConfig().Plec
type rowErr struct {
ID string `json:"id"`
Message string `json:"message"`
}
var rowErrors []rowErr
var valid []plecRow
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
for _, row := range req.Rows {
if row.Path == "" || !fileExists(row.Path) {
rowErrors = append(rowErrors, rowErr{row.ID, "File missing"})
} else if strings.TrimSpace(row.Iteration) == "" {
rowErrors = append(rowErrors, rowErr{row.ID, "Iteration name required"})
} else if !htmlRx.MatchString(row.Path) {
rowErrors = append(rowErrors, rowErr{row.ID, "Must be .html"})
} else {
valid = append(valid, row)
}
}
if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors})
return
}
if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."})
return
}
stamp := time.Now().Format("20060102150405")
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for i, row := range valid {
idx := i + 1
data, err := os.ReadFile(row.Path)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
return
}
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "text/html")
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
if _, err := fw.Write(data); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
}
if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
httpReq.Header.Set("Origin", cfg.OriginUrl)
httpReq.Header.Set("Referer", cfg.OriginUrl+"/")
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Accept", "*/*")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeJSON(w, map[string]any{"error": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
writeJSON(w, map[string]any{"error": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return
}
var preview any
if v, ok := parsed["preview"]; ok && v != nil {
preview = v
} else if v, ok := parsed["previewURL"]; ok {
preview = v
}
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := CopyToClipboard(req.Text); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true})
}
func OpenEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
OpenInBrowser(req.URL)
writeJSON(w, map[string]any{"ok": true})
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func appendTimestamp(filename, stamp string) string {
ext := filepath.Ext(filename)
base := filename
if ext != "" {
base = filename[:len(filename)-len(ext)]
}
return base + "_" + stamp + ext
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// createFormFileWithType is like multipart.Writer.CreateFormFile but lets us
// override the Content-Type (CreateFormFile hardcodes "application/octet-stream").
func createFormFileWithType(mw *multipart.Writer, field, filename, contentType string) (io.Writer, error) {
h := make(map[string][]string)
h["Content-Disposition"] = []string{
fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename),
}
h["Content-Type"] = []string{contentType}
return mw.CreatePart(h)
}
// encodeURICompatible mimics JS encodeURI() — keeps A-Za-z0-9 and these
// reserved/mark chars: ;,/?:@&=+$-_.!~*'()#
func encodeURICompatible(s string) string {
keep := func(c byte) bool {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
return true
}
switch c {
case ';', ',', '/', '?', ':', '@', '&', '=', '+', '$',
'-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
return true
}
return false
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if keep(c) {
b.WriteByte(c)
} else {
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}