/** * WP Data Optimizer — One-click User Migration Wizard frontend. * * - Confirms options + checkbox before starting. * - Calls REST endpoints under /wp-json/wpdo/v1/migration/. * - Polls /status every 500ms while job is active. * - Streams log lines with fade-in; live-updates ratio + progress bar. * * @since 2.8.0 */ (function () { 'use strict'; const POLL_INTERVAL_MS = 500; const config = window.wpdoMigrationWizard || {}; const i18n = config.i18n || {}; const restUrl = (config.restUrl || '').replace(/\/$/, ''); const nonce = config.nonce || ''; const $ = (id) => document.getElementById(id); const root = document.querySelector('.wpdo-migration-wizard'); if (!root) return; const els = { runPanel: $('wpdo-mw-run-panel'), progressPanel: $('wpdo-mw-progress-panel'), donePanel: $('wpdo-mw-done-panel'), startBtn: $('wpdo-mw-start'), cancelBtn: $('wpdo-mw-cancel'), resumeBtn: $('wpdo-mw-resume'), resetBtn: $('wpdo-mw-reset'), confirmBox: $('wpdo-mw-confirm-backup'), optBackup: $('wpdo-mw-opt-backup'), optStrict: $('wpdo-mw-opt-strict'), opt24h: $('wpdo-mw-opt-24h'), optAsync: $('wpdo-mw-opt-async'), optDryRun: $('wpdo-mw-opt-dryrun'), progressFill: $('wpdo-mw-progress-fill'), progressPct: $('wpdo-mw-progress-pct'), progressTitle: $('wpdo-mw-progress-title'), phaseName: $('wpdo-mw-current-phase-name'), elapsed: $('wpdo-mw-elapsed'), liveRatio: $('wpdo-mw-live-ratio-text'), log: $('wpdo-mw-log'), ratio: $('wpdo-mw-ratio'), residue: $('wpdo-mw-residue'), usermeta: $('wpdo-mw-usermeta'), doneSummary: $('wpdo-mw-done-summary'), }; let pollTimer = null; let startedAt = 0; let elapsedTimer = null; function show(panel) { [els.runPanel, els.progressPanel, els.donePanel].forEach((p) => { if (!p) return; if (p === panel) { p.removeAttribute('hidden'); } else { p.setAttribute('hidden', ''); } }); } function fetchJson(path, options = {}) { const url = restUrl + path; const opts = Object.assign( { method: 'GET', credentials: 'same-origin', headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' }, }, options ); return fetch(url, opts).then(async (r) => { let body; try { body = await r.json(); } catch (e) { body = null; } return { ok: r.ok, status: r.status, body }; }); } // ── Start handling ─────────────────────────────────────────────────── if (els.confirmBox && els.startBtn) { els.confirmBox.addEventListener('change', () => { els.startBtn.disabled = !els.confirmBox.checked; }); } if (els.startBtn) { els.startBtn.addEventListener('click', () => { if (!confirm(i18n.confirmStart || 'Start migration?')) return; els.startBtn.disabled = true; els.startBtn.textContent = '…'; const opts = { auto_backup: els.optBackup ? !!els.optBackup.checked : true, verify_strict: els.optStrict ? !!els.optStrict.checked : true, verify_24h: els.opt24h ? !!els.opt24h.checked : false, force_async: els.optAsync ? !!els.optAsync.checked : false, dry_run: els.optDryRun ? !!els.optDryRun.checked : false, }; fetchJson('/migration/start', { method: 'POST', body: JSON.stringify(opts), }).then((res) => { if (!res.ok) { if (res.body && res.body.reason === 'nothing_to_do') { alert(i18n.nothingToDo); els.startBtn.disabled = false; els.startBtn.textContent = '🚀'; return; } alert((res.body && res.body.error) || 'Start failed'); els.startBtn.disabled = false; els.startBtn.textContent = '🚀'; return; } startedAt = Date.now(); show(els.progressPanel); startElapsedTimer(); startPolling(); }); }); } // ── Cancel & Resume ────────────────────────────────────────────────── if (els.cancelBtn) { els.cancelBtn.addEventListener('click', () => { if (!confirm(i18n.confirmCancel || 'Cancel?')) return; els.cancelBtn.disabled = true; fetchJson('/migration/cancel', { method: 'POST' }).then(() => { stopPolling(); stopElapsedTimer(); setTimeout(() => location.reload(), 600); }); }); } if (els.resumeBtn) { els.resumeBtn.addEventListener('click', () => { els.resumeBtn.disabled = true; fetchJson('/migration/resume', { method: 'POST' }).then((res) => { if (res.ok) { els.resumeBtn.setAttribute('hidden', ''); els.cancelBtn.disabled = false; startPolling(); } else { alert((res.body && res.body.error) || 'Resume failed'); els.resumeBtn.disabled = false; } }); }); } if (els.resetBtn) { els.resetBtn.addEventListener('click', () => location.reload()); } // ── Polling ────────────────────────────────────────────────────────── function startPolling() { if (pollTimer) return; const tick = () => { fetchJson('/migration/status').then((res) => { if (!res.ok || !res.body) { pollTimer = setTimeout(tick, POLL_INTERVAL_MS); return; } renderStatus(res.body); const state = res.body.state; if (state === 'running') { pollTimer = setTimeout(tick, POLL_INTERVAL_MS); } else if (state === 'completed') { stopPolling(); stopElapsedTimer(); renderDone(res.body); } else if (state === 'failed') { stopPolling(); stopElapsedTimer(); renderFailed(res.body); } else if (state === 'cancelled' || state === 'idle') { stopPolling(); stopElapsedTimer(); } }); }; tick(); } function stopPolling() { if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } function startElapsedTimer() { if (elapsedTimer) return; elapsedTimer = setInterval(() => { if (!els.elapsed) return; const sec = (Date.now() - startedAt) / 1000; els.elapsed.textContent = sec.toFixed(1) + 's'; }, 100); } function stopElapsedTimer() { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } } // ── Render ─────────────────────────────────────────────────────────── function renderStatus(s) { if (els.progressFill) { els.progressFill.style.width = (s.overall_progress || 0) + '%'; } if (els.progressPct) { els.progressPct.textContent = (s.overall_progress || 0) + '%'; } if (els.phaseName) { els.phaseName.textContent = s.phase || ''; } if (els.liveRatio && s.metrics) { els.liveRatio.textContent = '1:' + (s.metrics.ratio_start || '?') + ' → 1:' + (s.metrics.ratio_now || '?'); } // Update top metrics live too if (els.ratio && s.metrics && s.metrics.ratio_now != null) { els.ratio.textContent = '1:' + s.metrics.ratio_now; } if (els.residue && s.metrics && s.metrics.eav_rows_now != null) { els.residue.textContent = String(s.metrics.eav_rows_now); } updateLog(s.log || []); } let lastLogLength = 0; function updateLog(lines) { if (!els.log) return; if (lines.length === lastLogLength) return; els.log.textContent = lines.join('\n'); els.log.scrollTop = els.log.scrollHeight; lastLogLength = lines.length; // Apply a subtle highlight on the last line via CSS animation els.log.classList.remove('wpdo-mw-log-flash'); // Force reflow so re-adding the class re-triggers the animation // eslint-disable-next-line no-unused-expressions void els.log.offsetWidth; els.log.classList.add('wpdo-mw-log-flash'); } function renderDone(s) { show(els.donePanel); if (!els.doneSummary) return; const m = s.metrics || {}; const elapsed = ((s.completed_at || 0) - (s.started_at || 0)); els.doneSummary.innerHTML = ''; const lines = [ ['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')], ['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)], ['mode', (m.mode_start || '?') + ' → aeav_only'], ['耗時', elapsed + ' 秒'], ['備份', s.backup_path || '(無)'], ]; lines.forEach(([label, value]) => { const div = document.createElement('div'); div.className = 'wpdo-mw-done-line'; const lab = document.createElement('span'); lab.textContent = label + ':'; const val = document.createElement('strong'); val.textContent = value; div.append(lab, val); els.doneSummary.appendChild(div); }); } function renderFailed(s) { els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel'); els.progressTitle.classList.add('wpdo-mw-failed'); if (els.resumeBtn) { els.resumeBtn.removeAttribute('hidden'); els.resumeBtn.disabled = false; } } // ── On-load: if a job is already running, hop into progress mode ──── const initialState = root.getAttribute('data-state'); if (initialState === 'running' || initialState === 'paused') { startedAt = Date.now(); show(els.progressPanel); startElapsedTimer(); startPolling(); } else if (initialState === 'failed') { show(els.progressPanel); renderFailed({ state: 'failed' }); } else if (initialState === 'completed') { // Already shown via PHP hidden flag — but populate summary if available fetchJson('/migration/status').then((res) => { if (res.ok && res.body && res.body.state === 'completed') { renderDone(res.body); } }); } })();