U2 Magic Form

Automatically configures minimum-cost magic and optionally applies it before downloading torrents.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         U2 Magic Form
// @namespace    https://u2.dmhy.org/
// @version      2.1
// @description  Automatically configures minimum-cost magic and optionally applies it before downloading torrents.
// @author       LightArrowsEXE
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @match        *://u2.dmhy.org/userdetails.php*
// @match        *://u2.dmhy.org/torrents.php*
// @match        *://u2.dmhy.org/details.php*
// @match        *://u2.dmhy.org/promotion.php*
// @icon         https://u2.dmhy.org/favicon.ico
// @license      MIT
// @run-at       document-end
// @noframes
// ==/UserScript==

(function () {
    'use strict';

    const SCRIPT_NAME = 'U2 Magic Form';

    const STORAGE_PREFIX = 'u2-magic-form:';
    const TORRENT_CACHE_PREFIX = `${STORAGE_PREFIX}torrent:`;
    const SETTINGS_KEY = `${STORAGE_PREFIX}settings`;

    const MAGIC_CACHE_MS = hours(23);
    const MAGIC_HOURS = '24';
    const DEFAULT_COST_LIMIT = 250_000;
    const REQUEST_TIMEOUT_MS = 15_000;
    const COST_WAIT_MS = 5_000;

    const DEFAULT_SETTINGS = Object.freeze({
        autoApplyOnDownload: true,
        costLimit: DEFAULT_COST_LIMIT,
        overLimitAction: 'warn', // skip | warn | apply
    });

    const MAGIC_FORM_SELECTORS = Object.freeze({
        hours: 'input[name="hours"]',
        selfRadio: '#s_user_self, input[name="user"][value="SELF"], input[name="user"][value="self"]',
        promotion: 'select[name="promotion"]',
        queryButton: '#btn_query',
        submitButton: '#btn_submit',
        priceLabel: '#label_price',
    });

    const OVER_LIMIT_ACTIONS = new Set(['skip', 'warn', 'apply']);
    const ALLOWED_PATHS = new Set(['/userdetails.php', '/torrents.php', '/details.php', '/promotion.php']);

    const BUTTON_SETS = Object.freeze({
        ok: [
            { value: 'ok', label: 'OK', primary: true },
        ],
        overLimit: [
            { value: 'apply', label: 'Apply magic and download', primary: true },
            { value: 'skip', label: 'Download without magic' },
            { value: 'cancel', label: 'Cancel download' },
        ],
        applyFailed: [
            { value: 'retry', label: 'Retry', primary: true },
            { value: 'download', label: 'Download without magic' },
            { value: 'cancel', label: 'Cancel download' },
        ],
        overLimitSetting: [
            { value: 'warn', label: 'Ask every time' },
            { value: 'skip', label: 'Download without magic' },
            { value: 'apply', label: 'Apply magic anyway' },
            { value: 'cancel', label: 'Cancel' },
        ],
    });

    let settingsCache = null;

    init();

    function init() {
        if (!isAllowedPage()) return;

        injectStyles();
        registerMenuCommands();
        pruneOldCaches();

        if (isMagicPage()) configureVisibleMagicForm();

        document.addEventListener('click', onDownloadClick, true);
    }

    async function onDownloadClick(event) {
        const settings = readSettings();
        if (!settings.autoApplyOnDownload) return;

        const link = event.target?.closest?.('a[href*="download.php?id="]');
        if (!link) return;

        const torrentId = getTorrentIdFromUrl(link.href);
        if (!torrentId || isMagicCached(torrentId)) return;

        const promotionContext = findPromotionContext(link);
        if (promotionContext && hasFreeOrEquivalentPromotion(promotionContext)) {
            writeTorrentCache(torrentId, 'already-free');
            return;
        }

        event.preventDefault();
        event.stopPropagation();

        await applyMagicThenDownload({ torrentId, downloadUrl: link.href, settings });
    }

    async function applyMagicThenDownload({ torrentId, downloadUrl, settings }) {
        while (true) {
            try {
                const result = await applyMagicHidden(torrentId, settings);

                if (result.cacheReason) writeTorrentCache(torrentId, result.cacheReason);
                if (result.continueDownload) window.location.href = downloadUrl;

                return;
            } catch (error) {
                console.error(`[${SCRIPT_NAME}] Failed to apply magic:`, error);

                const choice = await showApplyFailedDialog(error);

                if (choice === 'retry') continue;
                if (choice === 'download') window.location.href = downloadUrl;

                return;
            }
        }
    }

    async function applyMagicHidden(torrentId, settings) {
        const iframe = await loadHiddenMagicFrame(torrentId);

        try {
            const doc = iframe.contentDocument;
            if (!doc?.querySelector('form')) throw new Error('magic form not found');

            configureMagicForm(doc);

            const cost = await calculateMagicCost(doc);
            if (!Number.isFinite(cost)) throw new Error('could not read UCoin cost');

            const decision = await resolveOverLimitDecision(cost, settings);
            if (decision === 'cancel') return { continueDownload: false, cacheReason: null };
            if (decision === 'skip') return { continueDownload: true, cacheReason: 'over-limit-skipped' };

            await submitMagicInFrame(doc);

            return { continueDownload: true, cacheReason: 'magic-applied' };
        } finally {
            iframe.remove();
        }
    }

    async function resolveOverLimitDecision(cost, settings) {
        const limit = Number(settings.costLimit);

        if (!Number.isFinite(limit) || limit <= 0 || cost <= limit) return 'apply';
        if (settings.overLimitAction !== 'warn') return settings.overLimitAction;

        return showChoiceDialog({
            title: 'Magic cost above limit',
            message: [
                `Applying magic would cost ${formatUCoinsWithUnit(cost)}.`,
                `Your configured limit is ${formatUCoinsWithUnit(limit)}.`,
                '',
                'Choose what to do with this download.',
            ],
            buttons: BUTTON_SETS.overLimit,
        });
    }

    function showApplyFailedDialog(error) {
        return showChoiceDialog({
            title: 'Could not apply magic',
            message: [
                'U2 Magic Form could not apply magic automatically.',
                '',
                `Reason: ${errorMessage(error)}`,
                '',
                'Choose what to do with this download.',
            ],
            buttons: BUTTON_SETS.applyFailed,
        });
    }

    function loadHiddenMagicFrame(torrentId) {
        return new Promise((resolve, reject) => {
            const iframe = createNode('iframe', {
                className: 'u2-magic-hidden-frame',
                src: buildMagicUrl(torrentId),
            });

            const fail = error => {
                window.clearTimeout(timeout);
                iframe.remove();
                reject(error);
            };

            const timeout = window.setTimeout(() => {
                fail(new Error('magic page timed out'));
            }, REQUEST_TIMEOUT_MS);

            iframe.addEventListener('load', () => {
                window.clearTimeout(timeout);

                const doc = iframe.contentDocument;
                const text = normaliseText(doc?.body?.textContent || '').toLowerCase();

                if (text.includes('invalid id')) {
                    fail(new Error('U2 returned invalid ID'));
                    return;
                }

                if (!doc?.querySelector('form')) {
                    fail(new Error('magic form not found'));
                    return;
                }

                resolve(iframe);
            }, { once: true });

            document.body.append(iframe);
        });
    }

    function configureVisibleMagicForm() {
        const form = document.querySelector('form');
        if (!form || form.dataset.u2MagicConfigured === '1') return;

        form.dataset.u2MagicConfigured = '1';
        configureMagicForm(document);

        window.setTimeout(() => getMagicElement(document, 'queryButton')?.click(), 100);
    }

    function configureMagicForm(doc) {
        const hoursInput = getMagicElement(doc, 'hours');
        const selfRadio = getMagicElement(doc, 'selfRadio');
        const promotionSelect = getMagicElement(doc, 'promotion');

        if (hoursInput) setFieldValue(hoursInput, MAGIC_HOURS);

        if (selfRadio && !selfRadio.checked) {
            selfRadio.checked = true;
            selfRadio.dispatchEvent(new Event('change', { bubbles: true }));
        }

        if (promotionSelect && promotionSelect.value !== '2') {
            setFieldValue(promotionSelect, '2'); // Free
        }
    }

    async function calculateMagicCost(doc) {
        const queryButton = getMagicElement(doc, 'queryButton');
        if (!queryButton) return readMagicCost(doc);

        queryButton.click();

        const cost = await waitFor(() => {
            const value = readMagicCost(doc);
            return Number.isFinite(value) ? value : null;
        }, COST_WAIT_MS);

        return cost ?? readMagicCost(doc);
    }

    function submitMagicInFrame(doc) {
        return new Promise((resolve, reject) => {
            const submitButton = getMagicElement(doc, 'submitButton');
            const form = submitButton?.closest('form') || doc.querySelector('form');
            const iframe = doc.defaultView?.frameElement;

            if (!submitButton || !form || !iframe) {
                reject(new Error('magic submit button not found'));
                return;
            }

            const timeout = window.setTimeout(() => {
                reject(new Error('magic submit timed out'));
            }, REQUEST_TIMEOUT_MS);

            iframe.addEventListener('load', () => {
                window.clearTimeout(timeout);

                const text = normaliseText(iframe.contentDocument?.body?.textContent || '').toLowerCase();

                if (text.includes('invalid id')) {
                    reject(new Error('U2 returned invalid ID'));
                    return;
                }

                if (looksLikeErrorPage(text)) {
                    reject(new Error('U2 returned an error after submitting magic'));
                    return;
                }

                resolve();
            }, { once: true });

            submitButton.click();
        });
    }

    function looksLikeErrorPage(text) {
        if (!text) return false;

        return (
            text.includes('invalid id') ||
            text.includes('access denied') ||
            text.includes('permission denied') ||
            (
                text.includes('error') &&
                !text.includes('success') &&
                !text.includes('succeed')
            )
        );
    }

    function readMagicCost(doc) {
        const label = getMagicElement(doc, 'priceLabel');
        if (!label) return NaN;

        const candidates = [
            label.getAttribute('title'),
            label.querySelector('[title]')?.getAttribute('title'),
            label.textContent,
        ];

        for (const candidate of candidates) {
            const value = parseUCoins(candidate);
            if (Number.isFinite(value)) return value;
        }

        return NaN;
    }

    function getMagicElement(doc, key) {
        return doc.querySelector(MAGIC_FORM_SELECTORS[key]);
    }

    function parseUCoins(value) {
        const text = normaliseText(value);
        if (!text) return NaN;

        const match = text.match(/-?\d[\d,]*(?:\.\d+)?/);
        if (!match) return NaN;

        const parsed = Number.parseFloat(match[0].replace(/,/g, ''));
        return Number.isFinite(parsed) ? parsed : NaN;
    }

    function findPromotionContext(downloadLink) {
        const torrentName = downloadLink.closest('table.torrentname');

        if (torrentName) {
            const row = torrentName.closest('td.rowfollow, td.embedded, td')?.closest('tr');
            if (row) return row;
        }

        return downloadLink.closest('tr') || (location.pathname === '/details.php' ? document.body : null);
    }

    function hasFreeOrEquivalentPromotion(context) {
        const promotionText = normaliseText(getPromotionText(context)).toLowerCase();
        const contextText = normaliseText(context.textContent).toLowerCase();

        if (!promotionText && !contextText) return false;
        if (promotionText.includes('free') || contextText.includes('free')) return true;

        const hasOtherPromotion =
            promotionText.includes('other') ||
            contextText.includes('other') ||
            Boolean(context.querySelector?.('.custompromotion'));

        if (!hasOtherPromotion) return false;

        const hasUploadBonus =
            promotionText.includes('ul ratio') ||
            contextText.includes('ul ratio') ||
            promotionText.includes('upload') ||
            contextText.includes('upload') ||
            Boolean(context.querySelector?.('img.arrowup')) ||
            /\b\d+(?:\.\d+)?x\b/i.test(promotionText);

        return hasUploadBonus && hasNoDownloadRatioCost(context);
    }

    function hasNoDownloadRatioCost(context) {
        const downloadColumnText = getNamedColumnText(context, ['D.L.', 'DL', 'Download', 'Downloaded']);

        if (downloadColumnText !== null) {
            return normaliseText(downloadColumnText).toLowerCase() === '0 b';
        }

        const promotionText = normaliseText(getPromotionText(context)).toLowerCase();

        if (!promotionText) return true;

        if (/\b(?:download|dl)\s*ratio\b/.test(promotionText)) {
            return /(?:download|dl)\s*ratio[^0-9]*0(?:\.0+)?x?/.test(promotionText);
        }

        if (/\[(?:\d+)%\]/.test(promotionText)) {
            return /\[0%\]/.test(promotionText);
        }

        return true;
    }

    function getPromotionText(context) {
        const torrentName = context.querySelector?.('table.torrentname');
        return torrentName?.querySelector('tr:nth-child(2)')?.textContent || context.textContent || '';
    }

    function getNamedColumnText(context, names) {
        const row = context.matches?.('tr') ? context : context.closest?.('tr');
        if (!row) return null;

        const table = row.closest('table');
        const headerRow = table?.querySelector('tr');
        if (!headerRow) return null;

        const normalisedNames = names.map(normaliseColumnName);
        const columnIndex = [...headerRow.children].findIndex(header => {
            const text = header.textContent || header.querySelector('img')?.title || '';
            return normalisedNames.includes(normaliseColumnName(text));
        });

        return columnIndex < 0 ? null : row.children[columnIndex]?.textContent ?? null;
    }

    function registerMenuCommands() {
        if (typeof GM_registerMenuCommand !== 'function') return;

        const settings = readSettings();

        GM_registerMenuCommand(
            `Auto-apply at download: ${settings.autoApplyOnDownload ? 'enabled' : 'disabled'}`,
            toggleAutoApply
        );

        GM_registerMenuCommand(
            `Set ucoin limit: ${settings.costLimit > 0 ? formatUCoins(settings.costLimit) : 'no limit'}`,
            configureCostLimit
        );

        GM_registerMenuCommand(
            `Over-limit behaviour: ${settings.overLimitAction}`,
            configureOverLimitAction
        );
    }

    async function toggleAutoApply() {
        const settings = readSettings();
        const autoApplyOnDownload = !settings.autoApplyOnDownload;

        writeSettings({ ...settings, autoApplyOnDownload });

        await showInfoDialog(
            'Setting updated',
            `Auto-apply at download is now ${autoApplyOnDownload ? 'enabled' : 'disabled'}.`
        );
    }

    function configureCostLimit() {
        const settings = readSettings();

        const value = window.prompt(
            [
                'Enter the maximum UCoin cost for auto-applied magic.',
                '',
                'Use the full UCoin number shown in the tooltip, e.g. 250000 or 250,000.',
                'Enter 0 to disable the limit.',
            ].join('\n'),
            String(settings.costLimit)
        );

        if (value === null) return;

        const costLimit = parseUCoins(value);

        if (!Number.isFinite(costLimit) || costLimit < 0) {
            window.alert('Invalid UCoin limit.');
            return;
        }

        writeSettings({ ...settings, costLimit });
    }

    async function configureOverLimitAction() {
        const settings = readSettings();

        const value = await showChoiceDialog({
            title: 'Set over-limit behaviour',
            message: 'Choose what to do when magic would cost more than your configured limit.',
            buttons: withPrimary(BUTTON_SETS.overLimitSetting, settings.overLimitAction),
        });

        if (!value || value === 'cancel' || !OVER_LIMIT_ACTIONS.has(value)) return;

        writeSettings({ ...settings, overLimitAction: value });
    }

    function readSettings() {
        if (settingsCache) return settingsCache;

        const stable = GM_getValue(SETTINGS_KEY, null);
        const migrated = stable || readLegacySettings();

        settingsCache = normaliseSettings(migrated);
        GM_setValue(SETTINGS_KEY, settingsCache);

        return settingsCache;
    }

    function readLegacySettings() {
        const legacyKeys = [
            'u2-magic-form:v8:settings',
            'u2-magic-form:v7:settings',
            'u2-magic-form:v6:settings',
            'u2-magic-form:v5:settings',
        ];

        for (const key of legacyKeys) {
            const value = GM_getValue(key, null);
            if (value && typeof value === 'object') return value;
        }

        return null;
    }

    function normaliseSettings(settings = {}) {
        const costLimit = Number(settings.costLimit);

        return {
            autoApplyOnDownload: typeof settings.autoApplyOnDownload === 'boolean'
                ? settings.autoApplyOnDownload
                : DEFAULT_SETTINGS.autoApplyOnDownload,
            costLimit: Number.isFinite(costLimit) && costLimit >= 0
                ? costLimit
                : DEFAULT_SETTINGS.costLimit,
            overLimitAction: OVER_LIMIT_ACTIONS.has(settings.overLimitAction)
                ? settings.overLimitAction
                : DEFAULT_SETTINGS.overLimitAction,
        };
    }

    function writeSettings(settings) {
        settingsCache = normaliseSettings(settings);
        GM_setValue(SETTINGS_KEY, settingsCache);
    }

    function showInfoDialog(title, message) {
        return showChoiceDialog({
            title,
            message,
            buttons: BUTTON_SETS.ok,
        });
    }

    function showChoiceDialog({ title, message, buttons }) {
        return new Promise(resolve => {
            document.querySelector('.u2-magic-dialog-backdrop')?.remove();

            const backdrop = createNode('div', { className: 'u2-magic-dialog-backdrop' });
            const dialog = createNode('div', {
                className: 'u2-magic-dialog',
                role: 'dialog',
                ariaModal: 'true',
            });

            const buttonRow = createNode('div', { className: 'u2-magic-dialog-buttons' });

            const close = value => {
                backdrop.remove();
                document.removeEventListener('keydown', onKeyDown, true);
                resolve(value);
            };

            const onKeyDown = event => {
                if (event.key !== 'Escape') return;
                event.preventDefault();
                close('cancel');
            };

            for (const button of buttons) {
                const node = createNode('button', {
                    type: 'button',
                    className: `u2-magic-dialog-button${button.primary ? ' u2-magic-dialog-button-primary' : ''}`,
                    textContent: button.label,
                });

                node.addEventListener('click', () => close(button.value));
                buttonRow.append(node);
            }

            dialog.append(
                createNode('h2', { textContent: title }),
                createMessageBlock(message),
                buttonRow
            );

            backdrop.append(dialog);
            document.body.append(backdrop);
            document.addEventListener('keydown', onKeyDown, true);

            dialog.querySelector('.u2-magic-dialog-button-primary, .u2-magic-dialog-button')?.focus();
        });
    }

    function createMessageBlock(message) {
        const body = createNode('div', { className: 'u2-magic-dialog-body' });
        const lines = Array.isArray(message) ? message : String(message).split('\n');

        for (const line of lines) {
            body.append(createNode('p', { textContent: line || '\u00a0' }));
        }

        return body;
    }

    function createNode(tag, props = {}) {
        const node = document.createElement(tag);

        for (const [key, value] of Object.entries(props)) {
            if (key === 'className') node.className = value;
            else if (key === 'textContent') node.textContent = value;
            else if (key === 'ariaModal') node.setAttribute('aria-modal', value);
            else node[key] = value;
        }

        return node;
    }

    function withPrimary(buttons, primaryValue) {
        return buttons.map(button => ({ ...button, primary: button.value === primaryValue }));
    }

    function buildMagicUrl(torrentId) {
        const url = new URL('/promotion.php', location.origin);

        url.searchParams.set('action', 'magic');
        url.searchParams.set('torrent', torrentId);

        return url.toString();
    }

    function getTorrentIdFromUrl(url) {
        try {
            const id = new URL(url, location.href).searchParams.get('id');
            return id && /^\d+$/.test(id) ? id : null;
        } catch {
            return null;
        }
    }

    function isAllowedPage() {
        const url = new URL(location.href);

        if (!ALLOWED_PATHS.has(url.pathname)) return false;
        return url.pathname !== '/promotion.php' || url.searchParams.get('action') === 'magic';
    }

    function isMagicPage() {
        const url = new URL(location.href);
        return url.pathname === '/promotion.php' && url.searchParams.get('action') === 'magic';
    }

    function isMagicCached(torrentId) {
        return Boolean(readFreshCache(getTorrentKey(torrentId), MAGIC_CACHE_MS));
    }

    function writeTorrentCache(torrentId, reason) {
        writeCache(getTorrentKey(torrentId), { reason, torrentId });
    }

    function readFreshCache(key, maxAgeMs) {
        const cached = GM_getValue(key, null);
        if (!cached?.checkedAt) return null;

        return Date.now() - cached.checkedAt <= maxAgeMs ? cached : null;
    }

    function writeCache(key, value) {
        GM_setValue(key, { ...value, checkedAt: Date.now() });
    }

    function pruneOldCaches() {
        for (const key of safeListValues()) {
            if (!key.startsWith(TORRENT_CACHE_PREFIX)) continue;
            if (!readFreshCache(key, MAGIC_CACHE_MS)) GM_deleteValue(key);
        }
    }

    function safeListValues() {
        try {
            return typeof GM_listValues === 'function' ? GM_listValues() : [];
        } catch (error) {
            console.error(`[${SCRIPT_NAME}] Failed to list userscript storage values:`, error);
            return [];
        }
    }

    function getTorrentKey(torrentId) {
        return `${TORRENT_CACHE_PREFIX}${torrentId}`;
    }

    function setFieldValue(field, value) {
        field.value = value;
        field.dispatchEvent(new Event('input', { bubbles: true }));
        field.dispatchEvent(new Event('change', { bubbles: true }));
    }

    function waitFor(callback, timeoutMs) {
        return new Promise((resolve, reject) => {
            const startedAt = Date.now();

            const tick = () => {
                try {
                    const result = callback();

                    if (result !== null && result !== undefined) {
                        resolve(result);
                        return;
                    }

                    if (Date.now() - startedAt >= timeoutMs) {
                        resolve(null);
                        return;
                    }

                    window.setTimeout(tick, 100);
                } catch (error) {
                    reject(error);
                }
            };

            tick();
        });
    }

    function normaliseColumnName(text) {
        return normaliseText(text)
            .replace(/\s+/g, '')
            .replace(/[::]/g, '')
            .toLowerCase();
    }

    function normaliseText(text) {
        return String(text || '')
            .replace(/\u00a0/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function formatUCoins(value) {
        return Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 });
    }

    function formatUCoinsWithUnit(value) {
        const amount = Number(value);
        return `${formatUCoins(amount)} ${amount === 1 ? 'ucoin' : 'ucoins'}`;
    }

    function errorMessage(error) {
        return error instanceof Error && error.message ? error.message : 'unknown error';
    }

    function injectStyles() {
        if (document.getElementById('u2-magic-form-style')) return;

        const style = createNode('style', { id: 'u2-magic-form-style' });

        style.textContent = `
            .u2-magic-hidden-frame {
                position: fixed !important;
                left: -10000px !important;
                top: -10000px !important;
                width: 1px !important;
                height: 1px !important;
                border: 0 !important;
                opacity: 0 !important;
                pointer-events: none !important;
            }

            .u2-magic-dialog-backdrop {
                position: fixed;
                z-index: 2147483647;
                inset: 0;
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 20px;
                background: rgba(0, 0, 0, 0.55);
            }

            .u2-magic-dialog {
                box-sizing: border-box;
                width: min(520px, 100%);
                max-height: min(720px, 90vh);
                overflow: auto;
                padding: 18px;
                border: 1px solid #555;
                border-radius: 6px;
                background: #f8f8f8;
                color: #222;
                box-shadow: 0 10px 36px rgba(0, 0, 0, 0.35);
                font: 13px/1.45 Arial, Helvetica, sans-serif;
            }

            .u2-magic-dialog h2 {
                margin: 0 0 12px;
                padding: 0;
                border: 0;
                background: transparent;
                color: #111;
                font-size: 17px;
                line-height: 1.3;
            }

            .u2-magic-dialog-body {
                margin: 0 0 16px;
            }

            .u2-magic-dialog-body p {
                margin: 0 0 4px;
            }

            .u2-magic-dialog-buttons {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
                justify-content: flex-end;
            }

            .u2-magic-dialog-button {
                cursor: pointer;
                padding: 6px 10px;
                border: 1px solid #888;
                border-radius: 4px;
                background: #fff;
                color: #222;
                font: inherit;
            }

            .u2-magic-dialog-button:hover,
            .u2-magic-dialog-button:focus {
                border-color: #444;
                background: #eee;
                outline: none;
            }

            .u2-magic-dialog-button-primary {
                border-color: #24578a;
                background: #2f6fa8;
                color: #fff;
            }

            .u2-magic-dialog-button-primary:hover,
            .u2-magic-dialog-button-primary:focus {
                border-color: #183c5f;
                background: #24578a;
            }
        `;

        document.head.append(style);
    }

    function hours(value) {
        return value * 60 * 60 * 1000;
    }
})();