chore: initial snapshot of 2meet-data-optimizer v0.1.0
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
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user