d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
315 lines
9.5 KiB
JavaScript
315 lines
9.5 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
});
|
||
}
|
||
})();
|