Add status progress for batch tool operations

This commit is contained in:
2026-05-29 13:40:17 +08:00
parent 5625d94106
commit 80f155dfe8
14 changed files with 307 additions and 65 deletions

View File

@@ -100,6 +100,24 @@ function addFiles(files) {
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
renderRows();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
function renderRows() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
@@ -183,12 +201,31 @@ document.getElementById('upload').addEventListener('click', async () => {
}
statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy');
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
const j = await res.json();
let j = null;
try {
const res = await fetch('/api/plec/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }),
});
await readJSONStream(res, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message };
} else if (msg.type === 'rowErrors') {
j = { rowErrors: msg.rowErrors };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; return; }
if (j.rowErrors) {
statusEl.classList.remove('is-busy');
for (const re of j.rowErrors) {
@@ -260,11 +297,12 @@ type plecRow struct {
}
func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
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()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
cfg := LoadConfig().Plec
@@ -289,11 +327,11 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
}
}
if len(rowErrors) > 0 {
writeJSON(w, map[string]any{"rowErrors": rowErrors})
send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
return
}
if len(valid) == 0 {
writeJSON(w, map[string]any{"error": "Nothing to upload."})
send(map[string]any{"type": "error", "message": "Nothing to upload."})
return
}
@@ -303,37 +341,39 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
mw := multipart.NewWriter(&buf)
for i, row := range valid {
idx := i + 1
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
data, err := os.ReadFile(row.Path)
if err != nil {
writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()})
send(map[string]any{"type": "error", "message": "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()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
if _, err := fw.Write(data); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
send(map[string]any{"type": "error", "message": 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()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
}
if err := mw.Close(); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
httpReq, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
send(map[string]any{"type": "error", "message": err.Error()})
return
}
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
@@ -345,18 +385,18 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
writeJSON(w, map[string]any{"error": "Network error: " + err.Error()})
send(map[string]any{"type": "error", "message": "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))})
send(map[string]any{"type": "error", "message": 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)})
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return
}
var preview any
@@ -365,7 +405,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
} else if v, ok := parsed["previewURL"]; ok {
preview = v
}
writeJSON(w, map[string]any{"preview": preview, "raw": parsed, "rawText": string(respBody)})
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {