565 lines
18 KiB
JavaScript
565 lines
18 KiB
JavaScript
import $ from 'jquery';
|
|
import { config, translations } from 'grav-config';
|
|
import request from '../utils/request';
|
|
import toastr from '../utils/toastr';
|
|
|
|
const paramSep = config.param_sep;
|
|
const task = `task${paramSep}`;
|
|
const nonce = `admin-nonce${paramSep}${config.admin_nonce}`;
|
|
const base = `${config.base_url_relative}/update.json`;
|
|
|
|
const urls = {
|
|
restore: `${base}/${task}safeUpgradeRestore/${nonce}`,
|
|
snapshot: `${base}/${task}safeUpgradeSnapshot/${nonce}`,
|
|
status: `${base}/${task}safeUpgradeStatus/${nonce}`,
|
|
};
|
|
|
|
const NICETIME_PERIODS_SHORT = [
|
|
'NICETIME.SEC',
|
|
'NICETIME.MIN',
|
|
'NICETIME.HR',
|
|
'NICETIME.DAY',
|
|
'NICETIME.WK',
|
|
'NICETIME.MO',
|
|
'NICETIME.YR',
|
|
'NICETIME.DEC'
|
|
];
|
|
|
|
const NICETIME_PERIODS_LONG = [
|
|
'NICETIME.SECOND',
|
|
'NICETIME.MINUTE',
|
|
'NICETIME.HOUR',
|
|
'NICETIME.DAY',
|
|
'NICETIME.WEEK',
|
|
'NICETIME.MONTH',
|
|
'NICETIME.YEAR',
|
|
'NICETIME.DECADE'
|
|
];
|
|
|
|
const NICETIME_LENGTHS = [60, 60, 24, 7, 4.35, 12, 10];
|
|
const FAST_UPDATE_THRESHOLD_SECONDS = 60;
|
|
const FAST_REFRESH_INTERVAL_MS = 1000;
|
|
const SLOW_REFRESH_INTERVAL_MS = 60000;
|
|
const NICETIME_TRANSLATION_ROOTS = ['GRAV_CORE', 'GRAV'];
|
|
|
|
const NICETIME_BASE_FALLBACKS = {
|
|
SECOND: 'second',
|
|
MINUTE: 'minute',
|
|
HOUR: 'hour',
|
|
DAY: 'day',
|
|
WEEK: 'week',
|
|
MONTH: 'month',
|
|
YEAR: 'year',
|
|
DECADE: 'decade',
|
|
SEC: 'sec',
|
|
MIN: 'min',
|
|
HR: 'hr',
|
|
WK: 'wk',
|
|
MO: 'mo',
|
|
YR: 'yr',
|
|
DEC: 'dec'
|
|
};
|
|
|
|
const NICETIME_PLURAL_FALLBACKS = {
|
|
SECOND: 'seconds',
|
|
MINUTE: 'minutes',
|
|
HOUR: 'hours',
|
|
DAY: 'days',
|
|
WEEK: 'weeks',
|
|
MONTH: 'months',
|
|
YEAR: 'years',
|
|
DECADE: 'decades',
|
|
SEC: 'secs',
|
|
MIN: 'mins',
|
|
HR: 'hrs',
|
|
WK: 'wks',
|
|
MO: 'mos',
|
|
YR: 'yrs',
|
|
DEC: 'decs'
|
|
};
|
|
|
|
const getTranslationKey = (key) => {
|
|
for (const root of NICETIME_TRANSLATION_ROOTS) {
|
|
const catalog = translations[root];
|
|
if (catalog && Object.prototype.hasOwnProperty.call(catalog, key)) {
|
|
const value = catalog[key];
|
|
if (typeof value === 'string' && value.trim() !== '') {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const nicetimeHasKey = (key) => typeof getTranslationKey(key) === 'string';
|
|
|
|
const nicetimeTranslate = (key, fallback) => {
|
|
const value = getTranslationKey(key);
|
|
if (typeof value === 'string' && value.length) {
|
|
return value;
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
const getFallbackForPeriodKey = (key) => {
|
|
const normalized = key.replace(/^GRAV\./, '');
|
|
const period = normalized.replace(/^NICETIME\./, '');
|
|
const plural = /_PLURAL/.test(period);
|
|
const baseKey = period.replace(/_PLURAL(_MORE_THAN_TWO)?$/, '');
|
|
const base = NICETIME_BASE_FALLBACKS[baseKey] || baseKey.toLowerCase();
|
|
|
|
if (!plural) {
|
|
return base;
|
|
}
|
|
|
|
const pluralKey = NICETIME_PLURAL_FALLBACKS[baseKey];
|
|
if (pluralKey) {
|
|
return pluralKey;
|
|
}
|
|
|
|
if (base.endsWith('y')) {
|
|
return `${base.slice(0, -1)}ies`;
|
|
}
|
|
|
|
if (base.endsWith('s')) {
|
|
return `${base}es`;
|
|
}
|
|
|
|
return `${base}s`;
|
|
};
|
|
|
|
const parseTimestampValue = (input) => {
|
|
if (input instanceof Date) {
|
|
return Math.floor(input.getTime() / 1000);
|
|
}
|
|
|
|
if (typeof input === 'number' && Number.isFinite(input)) {
|
|
return input > 1e12 ? Math.floor(input / 1000) : Math.floor(input);
|
|
}
|
|
|
|
if (typeof input === 'string') {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const numeric = Number(trimmed);
|
|
if (!Number.isNaN(numeric) && trimmed === String(numeric)) {
|
|
return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
|
|
}
|
|
|
|
const parsed = Date.parse(trimmed);
|
|
if (!Number.isNaN(parsed)) {
|
|
return Math.floor(parsed / 1000);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const computeNicetime = (input, { longStrings = false, showTense = false } = {}) => {
|
|
if (input === null || input === undefined || input === '') {
|
|
return nicetimeTranslate('NICETIME.NO_DATE_PROVIDED', 'No date provided');
|
|
}
|
|
|
|
const unixDate = parseTimestampValue(input);
|
|
if (unixDate === null) {
|
|
return nicetimeTranslate('NICETIME.BAD_DATE', 'Bad date');
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const periods = (longStrings ? NICETIME_PERIODS_LONG : NICETIME_PERIODS_SHORT).slice();
|
|
|
|
let difference;
|
|
let tense;
|
|
|
|
if (now > unixDate) {
|
|
difference = now - unixDate;
|
|
tense = nicetimeTranslate('NICETIME.AGO', 'ago');
|
|
} else if (now === unixDate) {
|
|
difference = 0;
|
|
tense = nicetimeTranslate('NICETIME.JUST_NOW', 'just now');
|
|
} else {
|
|
difference = unixDate - now;
|
|
tense = nicetimeTranslate('NICETIME.FROM_NOW', 'from now');
|
|
}
|
|
|
|
if (now === unixDate) {
|
|
return tense;
|
|
}
|
|
|
|
let index = 0;
|
|
while (index < NICETIME_LENGTHS.length - 1 && difference >= NICETIME_LENGTHS[index]) {
|
|
difference /= NICETIME_LENGTHS[index];
|
|
index += 1;
|
|
}
|
|
|
|
difference = Math.round(difference);
|
|
let periodKey = periods[index];
|
|
|
|
if (difference !== 1) {
|
|
periodKey += '_PLURAL';
|
|
const moreThanTwoKey = `${periodKey}_MORE_THAN_TWO`;
|
|
if (difference > 2 && nicetimeHasKey(moreThanTwoKey)) {
|
|
periodKey = moreThanTwoKey;
|
|
}
|
|
}
|
|
|
|
const labelFallback = periodKey.split('.').pop().toLowerCase();
|
|
const fallbackLabel = getFallbackForPeriodKey(periodKey) || labelFallback;
|
|
const periodLabel = nicetimeTranslate(periodKey, fallbackLabel);
|
|
const timeString = `${difference} ${periodLabel}`;
|
|
|
|
return showTense ? `${timeString} ${tense}` : timeString;
|
|
};
|
|
|
|
const parseBoolAttribute = (element, attributeName, defaultValue = false) => {
|
|
const rawValue = element.getAttribute(attributeName);
|
|
if (rawValue === null) {
|
|
return defaultValue;
|
|
}
|
|
|
|
const normalized = rawValue.trim().toLowerCase();
|
|
if (normalized === '') {
|
|
return true;
|
|
}
|
|
|
|
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
|
};
|
|
|
|
const initialiseNicetimeUpdater = () => {
|
|
const selector = '[data-nicetime-timestamp]';
|
|
if (!document.querySelector(selector)) {
|
|
return null;
|
|
}
|
|
|
|
const update = () => {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
let youngestAge = Infinity;
|
|
|
|
document.querySelectorAll(selector).forEach((element) => {
|
|
const timestamp = element.getAttribute('data-nicetime-timestamp');
|
|
const longStrings = parseBoolAttribute(element, 'data-nicetime-long', false);
|
|
const showTense = parseBoolAttribute(element, 'data-nicetime-tense', false);
|
|
const unixTimestamp = parseTimestampValue(timestamp);
|
|
if (unixTimestamp !== null) {
|
|
const age = Math.max(0, nowSeconds - unixTimestamp);
|
|
if (age < youngestAge) {
|
|
youngestAge = age;
|
|
}
|
|
}
|
|
|
|
const updated = computeNicetime(timestamp, { longStrings, showTense });
|
|
|
|
if (updated && element.textContent !== updated) {
|
|
element.textContent = updated;
|
|
}
|
|
});
|
|
|
|
return youngestAge;
|
|
};
|
|
|
|
let timerId = null;
|
|
|
|
const scheduleNext = (lastAge) => {
|
|
const useFastInterval = Number.isFinite(lastAge) && lastAge < FAST_UPDATE_THRESHOLD_SECONDS;
|
|
const delay = useFastInterval ? FAST_REFRESH_INTERVAL_MS : SLOW_REFRESH_INTERVAL_MS;
|
|
|
|
timerId = window.setTimeout(() => {
|
|
const nextAge = update();
|
|
scheduleNext(nextAge);
|
|
}, delay);
|
|
};
|
|
|
|
const destroy = () => {
|
|
if (timerId !== null) {
|
|
window.clearTimeout(timerId);
|
|
timerId = null;
|
|
}
|
|
};
|
|
|
|
const initialAge = update();
|
|
scheduleNext(initialAge);
|
|
|
|
window.addEventListener('beforeunload', destroy, { once: true });
|
|
|
|
return { update, destroy };
|
|
};
|
|
|
|
class RestoreManager {
|
|
constructor() {
|
|
this.job = null;
|
|
this.pollTimer = null;
|
|
this.pollFailures = 0;
|
|
|
|
$(document).on('click', '[data-restore-snapshot]', (event) => {
|
|
event.preventDefault();
|
|
const button = $(event.currentTarget);
|
|
if (this.job) {
|
|
return;
|
|
}
|
|
this.startRestore(button);
|
|
});
|
|
|
|
$(document).on('click', '[data-create-snapshot]', (event) => {
|
|
event.preventDefault();
|
|
const button = $(event.currentTarget);
|
|
if (this.job) {
|
|
return;
|
|
}
|
|
this.startSnapshot(button);
|
|
});
|
|
}
|
|
|
|
startSnapshot(button) {
|
|
let label = null;
|
|
if (typeof window !== 'undefined' && window.prompt) {
|
|
const promptMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_PROMPT || 'Enter an optional snapshot label';
|
|
const input = window.prompt(promptMessage, '');
|
|
if (input === null) {
|
|
return;
|
|
}
|
|
label = input.trim();
|
|
if (label === '') {
|
|
label = null;
|
|
}
|
|
}
|
|
|
|
button.prop('disabled', true).addClass('is-loading');
|
|
|
|
const body = {};
|
|
if (label) {
|
|
body.label = label;
|
|
}
|
|
|
|
request(urls.snapshot, { method: 'post', body }, (response) => {
|
|
button.prop('disabled', false).removeClass('is-loading');
|
|
|
|
if (!response) {
|
|
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
|
|
return;
|
|
}
|
|
|
|
if (response.status === 'error') {
|
|
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
|
|
return;
|
|
}
|
|
|
|
const data = response.data || {};
|
|
const jobId = data.job_id || (data.job && data.job.id);
|
|
if (!jobId) {
|
|
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.';
|
|
toastr.error(message);
|
|
return;
|
|
}
|
|
|
|
this.job = {
|
|
id: jobId,
|
|
operation: 'snapshot',
|
|
snapshot: null,
|
|
label
|
|
};
|
|
this.pollFailures = 0;
|
|
|
|
const descriptor = label || jobId;
|
|
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_RUNNING
|
|
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_RUNNING.replace('%s', descriptor)
|
|
: 'Creating snapshot...';
|
|
toastr.info(runningMessage);
|
|
this.schedulePoll();
|
|
});
|
|
}
|
|
|
|
startRestore(button) {
|
|
const snapshot = button.data('restore-snapshot');
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
|
|
button.prop('disabled', true).addClass('is-loading');
|
|
|
|
const body = { snapshot };
|
|
request(urls.restore, { method: 'post', body }, (response) => {
|
|
button.prop('disabled', false).removeClass('is-loading');
|
|
|
|
if (!response) {
|
|
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
|
return;
|
|
}
|
|
|
|
if (response.status === 'error') {
|
|
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
|
return;
|
|
}
|
|
|
|
const data = response.data || {};
|
|
const jobId = data.job_id || (data.job && data.job.id);
|
|
if (!jobId) {
|
|
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.';
|
|
toastr.error(message);
|
|
return;
|
|
}
|
|
|
|
this.job = {
|
|
id: jobId,
|
|
snapshot,
|
|
operation: 'restore',
|
|
};
|
|
this.pollFailures = 0;
|
|
|
|
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_RUNNING
|
|
? translations.PLUGIN_ADMIN.RESTORE_GRAV_RUNNING.replace('%s', snapshot)
|
|
: `Restoring snapshot ${snapshot}...`;
|
|
toastr.info(runningMessage);
|
|
this.schedulePoll();
|
|
});
|
|
}
|
|
|
|
schedulePoll(delay = 1200) {
|
|
this.clearPoll();
|
|
this.pollTimer = setTimeout(() => this.pollStatus(), delay);
|
|
}
|
|
|
|
clearPoll() {
|
|
if (this.pollTimer) {
|
|
clearTimeout(this.pollTimer);
|
|
this.pollTimer = null;
|
|
}
|
|
}
|
|
|
|
pollStatus() {
|
|
if (!this.job) {
|
|
return;
|
|
}
|
|
|
|
const jobId = this.job.id;
|
|
let handled = false;
|
|
|
|
request(`${urls.status}?job=${encodeURIComponent(jobId)}`, { silentErrors: true }, (response) => {
|
|
handled = true;
|
|
this.pollFailures = 0;
|
|
|
|
if (!response || response.status !== 'success') {
|
|
this.schedulePoll();
|
|
return;
|
|
}
|
|
|
|
const data = response.data || {};
|
|
const job = data.job || {};
|
|
const progress = data.progress || {};
|
|
|
|
const stage = progress.stage || null;
|
|
const status = job.status || progress.status || null;
|
|
const operation = progress.operation || this.job.operation || null;
|
|
|
|
if (!this.job.snapshot && progress.snapshot) {
|
|
this.job.snapshot = progress.snapshot;
|
|
} else if (!this.job.snapshot && job.result && job.result.snapshot) {
|
|
this.job.snapshot = job.result.snapshot;
|
|
}
|
|
|
|
if (!this.job.label && progress.label) {
|
|
this.job.label = progress.label;
|
|
} else if (!this.job.label && job.result && job.result.label) {
|
|
this.job.label = job.result.label;
|
|
}
|
|
|
|
if (stage === 'error' || status === 'error') {
|
|
const message = job.error || progress.message || (operation === 'snapshot'
|
|
? translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.'
|
|
: translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
|
|
toastr.error(message);
|
|
this.job = null;
|
|
this.clearPoll();
|
|
return;
|
|
}
|
|
|
|
if (stage === 'complete' || status === 'success') {
|
|
if (operation === 'snapshot') {
|
|
const snapshotId = progress.snapshot || (job.result && job.result.snapshot) || this.job.snapshot || '';
|
|
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
|
|
let displayName = labelValue || snapshotId || (translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot');
|
|
if (labelValue && snapshotId && labelValue !== snapshotId) {
|
|
displayName = `${labelValue} (${snapshotId})`;
|
|
}
|
|
const successMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_SUCCESS
|
|
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_SUCCESS.replace('%s', displayName)
|
|
: (snapshotId ? `Snapshot ${displayName} created.` : 'Snapshot created.');
|
|
toastr.success(successMessage);
|
|
this.job = null;
|
|
this.clearPoll();
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
return;
|
|
}
|
|
|
|
const snapshotId = progress.snapshot || this.job.snapshot || '';
|
|
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
|
|
let snapshotDisplay = snapshotId || labelValue;
|
|
if (labelValue && snapshotId && labelValue !== snapshotId) {
|
|
snapshotDisplay = `${labelValue} (${snapshotId})`;
|
|
} else if (!snapshotDisplay) {
|
|
snapshotDisplay = translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot';
|
|
}
|
|
const version = (job.result && job.result.version) || progress.version || '';
|
|
let successMessage;
|
|
if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_MESSAGE && version) {
|
|
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_MESSAGE.replace('%1$s', snapshotDisplay).replace('%2$s', version);
|
|
} else if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_SIMPLE) {
|
|
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_SIMPLE.replace('%s', snapshotDisplay);
|
|
} else {
|
|
successMessage = version ? `Snapshot ${snapshotDisplay} restored (Grav ${version}).` : `Snapshot ${snapshotDisplay} restored.`;
|
|
}
|
|
toastr.success(successMessage);
|
|
this.job = null;
|
|
this.clearPoll();
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
return;
|
|
}
|
|
|
|
this.schedulePoll();
|
|
}).then(() => {
|
|
if (!handled) {
|
|
this.handleSilentFailure();
|
|
}
|
|
});
|
|
}
|
|
|
|
handleSilentFailure() {
|
|
if (!this.job) {
|
|
return;
|
|
}
|
|
|
|
this.pollFailures += 1;
|
|
const operation = this.job.operation || 'restore';
|
|
const snapshot = this.job.snapshot || '';
|
|
|
|
if (this.pollFailures >= 3) {
|
|
let message;
|
|
if (operation === 'snapshot') {
|
|
message = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FALLBACK || 'Snapshot creation may have completed. Reloading...';
|
|
} else {
|
|
message = snapshot
|
|
? `Snapshot ${snapshot} restore is completing. Reloading...`
|
|
: 'Snapshot restore is completing. Reloading...';
|
|
}
|
|
toastr.info(message);
|
|
this.job = null;
|
|
this.clearPoll();
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
|
|
return;
|
|
}
|
|
|
|
const delay = Math.min(5000, 1200 * this.pollFailures);
|
|
this.schedulePoll(delay);
|
|
}
|
|
}
|
|
|
|
// Initialize restore manager when tools view loads.
|
|
$(document).ready(() => {
|
|
initialiseNicetimeUpdater();
|
|
new RestoreManager();
|
|
});
|