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:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+314
View File
@@ -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);
}
});
}
})();