Script Notifier

Sistema de notificações para UserScripts.

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/549920/1685076/Script%20Notifier.js

// ==UserScript==
// @name               Script Notifier
// @namespace          http://github.com/0H4S
// @version            1.4
// @author             OHAS
// @description        Sistema de notificação para UserScripts.
// @license            CC-BY-NC-ND-4.0
// @copyright          2025 OHAS. All Rights Reserved. (https://gist.github.com/0H4S/ae2fa82957a089576367e364cbf02438)
// ==/UserScript==
/*
    Copyright Notice & Terms of Use
    Copyright © 2025 OHAS. All Rights Reserved.

    This software is the exclusive property of OHAS and is licensed for personal, non-commercial use only.

    You may:
    - Install, use, and inspect the code for learning or personal purposes.

    You may NOT (without prior written permission from the author):
    - Copy, redistribute, or republish this software.
    - Modify, sell, or use it commercially.
    - Create derivative works.

    For questions, permission requests, or alternative licensing, please contact via
    - GitHub:       https://github.com/0H4S
    - Greasy Fork:  https://greasyfork.org/users/1464180

    This software is provided "as is", without warranty of any kind. The author is not liable for any damages arising from its use.
*/
class ScriptNotifier {
    constructor({ notificationsUrl, scriptVersion, currentLang }) {
        this.NOTIFICATIONS_URL = notificationsUrl;
        this.SCRIPT_VERSION = scriptVersion;
        this.forcedLang = currentLang;
        this.currentLang = 'en';
        this.STAGGER_DELAY = 70;
        this.DISMISSED_NOTIFICATIONS_KEY = 'DismissedNotifications';
        this.NOTIFICATIONS_ENABLED_KEY = 'NotificationsEnabled';
        this.LANG_STORAGE_KEY = 'UserScriptLang';
        this.hostElement = null;
        this.shadowRoot = null;
        this.activeNotifications = [];
        this.uiStrings = {};
        this.icons = this._getIcons();
        this.scriptPolicy = this._createPolicy();
    }
    async run() {
        await this._initializeLanguage();
        await this._registerUserCommands();
        setTimeout(() => this.checkForNotifications(), 1500);
    }
    forceShowAllNotifications() {
        this.checkForNotifications(true);
    }
    checkForNotifications(forceShow = false) {
        if (!this.NOTIFICATIONS_URL || this.NOTIFICATIONS_URL.includes("SEU_USUARIO")) return;
        GM_xmlhttpRequest({
            method: 'GET',
            url: `${this.NOTIFICATIONS_URL}?t=${new Date().getTime()}`,
            onload: async (response) => {
                if (response.status < 200 || response.status >= 300) {
                    console.error(`Script Notifier: Falha ao buscar notificações. Status: ${response.status}`);
                    return;
                }
                let jsonString;
                const isGistPage = this.NOTIFICATIONS_URL.includes('gist.github.com/') && !this.NOTIFICATIONS_URL.includes('usercontent');
                if (isGistPage) {
                    try {
                        const parser = new DOMParser();
                        const trustedHtml = this.scriptPolicy
                            ? this.scriptPolicy.createHTML(response.responseText)
                            : response.responseText;
                        const doc = parser.parseFromString(trustedHtml, 'text/html');
                        const codeLines = doc.querySelectorAll('table.highlight .blob-code-inner');

                        if (codeLines.length === 0) {
                            console.error('Script Notifier: A página Gist foi encontrada, mas nenhum conteúdo de código (.blob-code-inner) pôde ser extraído.');
                            return;
                        }
                        jsonString = Array.from(codeLines).map(line => line.innerText).join('\n');
                    } catch (e) {
                        console.error('Script Notifier: Falha ao analisar a página Gist.', e);
                        return;
                    }
                } else {
                    jsonString = response.responseText;
                }
                try {
                    const data = JSON.parse(jsonString);
                    const notifications = data.notifications || [];

                    if (forceShow) {
                        this.activeNotifications.forEach(n => n.element.remove());
                        this.activeNotifications = [];
                    }
                    await this._cleanupDismissedNotifications(notifications);
                    const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);

                    const notificationsToDisplay = notifications.filter(notification => {
                        if (this.activeNotifications.some(n => n.id === notification.id)) return false;
                        if (!forceShow && dismissed.includes(notification.id)) return false;
                        if (notification.expires && new Date(notification.expires) < new Date()) return false;
                        if (notification.targetVersion !== 'all' && notification.targetVersion !== this.SCRIPT_VERSION) return false;
                        if (notification.targetHostname && window.location.hostname !== notification.targetHostname) return false;
                        return true;
                    });
                    notificationsToDisplay.forEach((notification, index) => {
                        setTimeout(() => this.displayNotification(notification), index * 200);
                    });
                } catch (e) {
                    console.error('Script Notifier: Falha ao analisar o JSON das notificações.', e);
                }
            },
            onerror: (error) => {
                console.error('Script Notifier: Erro de rede ao buscar as notificações.', error);
            }
        });
    }
    async displayNotification(notification) {
        this._ensureHostElement();
        const notificationsEnabled = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
        if (notification.priority !== 'high' && !notificationsEnabled) return;
        const notificationId = `notification-${notification.id}`;
        if (this.shadowRoot.getElementById(notificationId)) return;
        const title = this._getTranslatedText(notification.title);
        const message = this._getTranslatedText(notification.message);
        if (!title && !message) return;
        const container = document.createElement('div');
        container.id = notificationId;
        container.className = 'notification-container';
        const notificationType = notification.type || 'info';
        container.dataset.type = notificationType;
        if (notification.customColor) {
            container.style.borderLeftColor = notification.customColor;
            container.style.setProperty('--type-color', notification.customColor);
        }
        let iconHTML = this.icons[notificationType] || this.icons['info'];
        if (notification.customIconSvg) {
            iconHTML = this._sanitizeAndStyleSvg(notification.customIconSvg);
        }
        const imageOrIconHTML = notification.imageUrl ?
            `<img src="${notification.imageUrl}" class="notification-image" alt="Notification Image">` :
            `<div class="notification-icon">${iconHTML}</div>`;
        const closeIconSVG = `<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg>`;
        const notificationHTML = `
          ${imageOrIconHTML}
          <div class="notification-content">
              <h3 class="notification-title">${this._prepareMessageHTML(title)}</h3>
              <div class="notification-message">${this._prepareMessageHTML(message)}</div>
          </div>
          <button class="dismiss-button" title="${this._getUIText('closeButtonTitle')}">${closeIconSVG}</button>
        `;
        this._setSafeInnerHTML(container, notificationHTML);
        if (notification.buttons && notification.buttons.length > 0) {
            const buttonsContainer = this._createButtons(notification.buttons, notification.id);
            container.querySelector('.notification-content').appendChild(buttonsContainer);
        }
        this.shadowRoot.appendChild(container);
        this.activeNotifications.push({ id: notification.id, element: container, isNew: true });
        this._updateNotificationPositions();
        container.querySelector('.dismiss-button').onclick = (e) => {
            e.stopPropagation();
            this._dismissNotification(notification.id);
        };
    }
    _createButtons(buttonDataArray, notificationId) {
        const buttonsContainer = document.createElement('div');
        buttonsContainer.className = 'notification-buttons';
        buttonDataArray.forEach((buttonData, index) => {
            const button = document.createElement('button');
            const buttonText = this._getTranslatedText(buttonData.text);
            this._setSafeInnerHTML(button, this._prepareMessageHTML(buttonText));
            button.className = 'notification-button';
            if (buttonData.backgroundColor) {
                button.style.backgroundColor = buttonData.backgroundColor;
                button.classList.add('custom-bg');
            } else if (index === 0) {
                button.classList.add('primary');
            }
            if (buttonData.textColor) {
                button.style.color = buttonData.textColor;
            }
            button.onclick = (e) => {
                e.stopPropagation();
                if (buttonData.action) {
                    switch (buttonData.action) {
                        case 'open_url': window.location.href = buttonData.value; break;
                        case 'open_url_new_tab': window.open(buttonData.value, '_blank'); break;
                    }
                }
                this._dismissNotification(notificationId);
            };
            buttonsContainer.appendChild(button);
        });
        return buttonsContainer;
    }
    async _dismissNotification(notificationId) {
        this._ensureHostElement();
        const notification = this.activeNotifications.find(n => n.id === notificationId);
        if (!notification) return;
        const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);
        if (!dismissed.includes(notificationId)) {
            dismissed.push(notificationId);
            await GM_setValue(this.DISMISSED_NOTIFICATIONS_KEY, dismissed);
        }
        notification.element.classList.remove('animate-in');
        notification.element.classList.add('animate-out');
        setTimeout(() => {
            this.activeNotifications = this.activeNotifications.filter(n => n.id !== notificationId);
            notification.element.remove();
            this._updateNotificationPositions();
        }, 600);
    }
    _updateNotificationPositions() {
        this._ensureHostElement();
        const spacingValue = parseInt(getComputedStyle(this.shadowRoot.host).getPropertyValue('--sn-spacing')) || 20;
        let currentTop = spacingValue;
        this.activeNotifications.forEach((notif, index) => {
            const { element } = notif;
            element.style.top = `${currentTop}px`;
            element.style.transitionDelay = `${index * this.STAGGER_DELAY}ms`;
            if (notif.isNew) {
                requestAnimationFrame(() => {
                    element.classList.add('animate-in');
                });
                delete notif.isNew;
            }
            currentTop += element.offsetHeight + (spacingValue / 2);
        });
    }
    _ensureHostElement() {
        const hostId = 'script-notifier-host';
        this.hostElement = document.getElementById(hostId);
        if (!this.hostElement) {
            this.hostElement = document.createElement('div');
            this.hostElement.id = hostId;
            document.body.appendChild(this.hostElement);
        }
        if (!this.hostElement.shadowRoot) {
            this.shadowRoot = this.hostElement.attachShadow({ mode: 'open' });
            const style = document.createElement('style');
            style.textContent = this._getNotifierStyles();
            this.shadowRoot.appendChild(style);
        } else {
            this.shadowRoot = this.hostElement.shadowRoot;
        }
    }
    async _initializeLanguage() {
        const supportedLanguages = ['pt-BR', 'en', 'es', 'zh-CN'];
        let lang = this.forcedLang || await GM_getValue(this.LANG_STORAGE_KEY) || navigator.language || 'en';
        if          (lang.startsWith('pt')) lang = 'pt-BR';
        else if     (lang.startsWith('es')) lang = 'es';
        else if     (lang.startsWith('zh')) lang = 'zh-CN';
        else if     (lang.startsWith('en')) lang = 'en';
        this.currentLang = supportedLanguages.includes(lang) ? lang : 'en';
        this.uiStrings = this._getUIStrings();
    }
    async _cleanupDismissedNotifications(serverNotifications) {
        const dismissed = await GM_getValue(this.DISMISSED_NOTIFICATIONS_KEY, []);
        if (dismissed.length === 0) return;
        const validServerIds = new Set(serverNotifications.filter(n => !n.expires || new Date(n.expires) >= new Date()).map(n => n.id));
        const cleanedDismissed = dismissed.filter(id => validServerIds.has(id));
        if (cleanedDismissed.length < dismissed.length) {
            await GM_setValue(this.DISMISSED_NOTIFICATIONS_KEY, cleanedDismissed);
        }
    }
    async _registerUserCommands() {
        const notificationsEnabled = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
        const toggleCommandText = notificationsEnabled ? this._getUIText('disableNotificationsCmd') : this._getUIText('enableNotificationsCmd');
        GM_registerMenuCommand(this._getUIText('showAllNotificationsCmd'), () => this.forceShowAllNotifications());
        GM_registerMenuCommand(toggleCommandText, async () => {
            const currentState = await GM_getValue(this.NOTIFICATIONS_ENABLED_KEY, true);
            await GM_setValue(this.NOTIFICATIONS_ENABLED_KEY, !currentState);
            window.location.reload();
        });
    }
    _createPolicy() {
        return window.trustedTypes ? window.trustedTypes.createPolicy('script-notifier-policy-unico', {
            createHTML: (input) => input
        }) : null;
    }
    _getUIText(key) { return this.uiStrings[key]?.[this.currentLang] || this.uiStrings[key]?.['en'] || ''; }
    _setSafeInnerHTML(element, html) {
        if (!element) return;
        element.innerHTML = this.scriptPolicy ? this.scriptPolicy.createHTML(html) : html;
    }
    _getTranslatedText(translationObject) {
        if (!translationObject) return '';
        if (typeof translationObject === 'string') return translationObject;
        return translationObject[this.currentLang] || translationObject[this.currentLang.split('-')[0]] || translationObject['en'] || '';
    }
    _prepareMessageHTML(text) { return text || ''; }
    _sanitizeAndStyleSvg(svgString) {
        try {
            const tempDiv = document.createElement('div');
            this._setSafeInnerHTML(tempDiv, svgString);
            const svgElement = tempDiv.querySelector('svg');
            if (!svgElement) return '';
            svgElement.setAttribute     ('fill', 'currentColor');
            svgElement.removeAttribute  ('width');
            svgElement.removeAttribute  ('height');
            svgElement.removeAttribute  ('style');
            svgElement.removeAttribute  ('class');
            return svgElement.outerHTML;
        } catch (e) {
            return '';
        }
    }
    _getUIStrings() {
        return {
            showAllNotificationsCmd: {
              'pt-BR':  '🔔 Notificações',
              'en':     '🔔 Notifications',
              'es':     '🔔 Notificaciones',
              'zh-CN':  '🔔 通知' },
            disableNotificationsCmd: {
              'pt-BR':  '❌ Desativar Notificações',
              'en':     '❌ Disable Notifications',
              'es':     '❌ Desactivar Notificaciones',
              'zh-CN':  '❌ 禁用通知' },
            enableNotificationsCmd: {
              'pt-BR':  '✅ Ativar Notificações',
              'en':     '✅ Enable Notifications',
              'es':     '✅ Activar Notificaciones',
              'zh-CN':  '✅ 启用通知' },
            closeButtonTitle: {
              'pt-BR':  'Fechar',
              'en':     'Close',
              'es':     'Cerrar',
              'zh-CN':  '关闭' }
        };
    }
    _getIcons() {
        return {
            success:    `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path></svg>`,
            warning:    `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"></path></svg>`,
            info:       `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></svg>`
        };
    }
    _getNotifierStyles() {
        return `
            :host {
                --sn-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                --sn-color-background: #fff;
                --sn-color-text-primary: #000;
                --sn-color-text-secondary: #333;
                --sn-color-border: #ddd;
                --sn-color-link: currentColor;
                --sn-color-link-underline: currentColor;
                --sn-color-dismiss: #999;
                --sn-color-dismiss-hover: #ff4d4d;
                --sn-shadow-default: 0 8px 20px rgba(0, 0, 0, 0.15);
                --sn-shadow-button-hover: 0 4px 10px rgba(0,0,0,0.2);
                --sn-card-background: rgba(0, 0, 0, 0.05);
                --sn-card-border: #ccc;
                --sn-scrollbar-track: #f1f1f1;
                --sn-scrollbar-thumb: #ccc;
                --sn-scrollbar-thumb-hover: #aaa;
                --sn-button-hover-bg: #555;
                --sn-button-hover-text: #fff;
                --sn-border-radius: 12px;
                --sn-border-radius-small: 6px;
                --sn-padding: 16px;
                --sn-notification-width: 380px;
                --sn-spacing: 20px;
                --sn-icon-size: 24px;
                --sn-image-size: 48px;
                --sn-font-size-title: 16px;
                --sn-font-size-body: 14px;
                --sn-font-weight-title: 600;
                --sn-message-max-height: 110px;
                --sn-animation-duration-fast: 0.2s;
                --sn-animation-duration-medium: 0.4s;
                --sn-animation-duration-slow: 0.8s;
            }

            @media (prefers-color-scheme: dark) {
                :host {
                    --sn-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                    --sn-color-background: #333;
                    --sn-color-text-primary: #fff;
                    --sn-color-text-secondary: #ddd;
                    --sn-color-border: #444;
                    --sn-color-link: currentColor;
                    --sn-color-link-underline: currentColor;
                    --sn-color-dismiss: #aaa;
                    --sn-color-dismiss-hover: #ff4d4d;
                    --sn-shadow-default: 0 8px 20px rgba(0, 0, 0, 0.5);
                    --sn-shadow-button-hover: 0 4px 12px rgba(0,0,0,0.4);
                    --sn-card-background: rgba(0, 0, 0, 0.1);
                    --sn-card-border: #555;
                    --sn-scrollbar-track: #444;
                    --sn-scrollbar-thumb: #666;
                    --sn-scrollbar-thumb-hover: #888;
                    --sn-button-hover-bg: #777;
                    --sn-button-hover-text: #fff;
                    --sn-border-radius: 12px;
                    --sn-border-radius-small: 6px;
                    --sn-padding: 16px;
                    --sn-notification-width: 380px;
                    --sn-spacing: 20px;
                    --sn-icon-size: 24px;
                    --sn-image-size: 48px;
                    --sn-font-size-title: 16px;
                    --sn-font-size-body: 14px;
                    --sn-font-weight-title: 600;
                    --sn-message-max-height: 110px;
                    --sn-animation-duration-fast: 0.2s;
                    --sn-animation-duration-medium: 0.4s;
                    --sn-animation-duration-slow: 0.8s;
                }
            }

            .notification-container {
                position: fixed;
                top: 0;
                right: var(--sn-spacing);
                z-index: 2147483647;
                width: var(--sn-notification-width);
                font-family: var(--sn-font-family);
                background-color: var(--sn-color-background);
                color: var(--sn-color-text-secondary);
                border-radius: var(--sn-border-radius);
                box-shadow: var(--sn-shadow-default);
                border: 1px solid var(--sn-color-border);
                display: flex;
                padding: var(--sn-padding);
                box-sizing: border-box;
                border-left: 5px solid transparent;
                opacity: 0;
                transform: translateX(120%);
                will-change: transform, opacity, top;
            }

            .notification-container.animate-in {
                opacity: 1;
                transform: translateX(0);
                transition: transform var(--sn-animation-duration-slow) cubic-bezier(0.22, 1.6, 0.5, 1), opacity var(--sn-animation-duration-medium) ease-out, top var(--sn-animation-duration-slow) cubic-bezier(0.22, 1.6, 0.5, 1);
            }

            .notification-container.animate-out {
                opacity: 0;
                transform: translateX(120%);
                transition: transform var(--sn-animation-duration-medium) cubic-bezier(0.6, -0.28, 0.735, 0.045), opacity var(--sn-animation-duration-medium) ease-out, top var(--sn-animation-duration-medium) ease-out;
            }

            .notification-container[data-type="success"] {
                --type-color: #22c55e;
            }

            .notification-container[data-type="warning"] {
                --type-color: #f97316;
            }

            .notification-container[data-type="info"] {
                --type-color: #3b82f6;
            }

            .notification-container[data-type] {
                border-left-color: var(--type-color);
            }

            .notification-icon {
                width: var(--sn-icon-size);
                height: var(--sn-icon-size);
                margin-right: 12px;
                flex-shrink: 0;
                color: var(--type-color);
            }

            .notification-image {
                width: var(--sn-image-size);
                height: var(--sn-image-size);
                border-radius: var(--sn-border-radius-small);
                object-fit: cover;
                flex-shrink: 0;
                margin-right: 15px;
            }

            .notification-content {
                flex-grow: 1;
                word-break: break-word;
            }

            .notification-title {
                margin: 0 0 8px;
                font-size: var(--sn-font-size-title);
                font-weight: var(--sn-font-weight-title);
                color: var(--sn-color-text-primary);
            }

            .notification-message {
                font-size: var(--sn-font-size-body);
                line-height: 1.5;
                max-height: var(--sn-message-max-height);
                overflow-y: auto;
                padding-right: 8px;
            }

            .notification-message ul,
            .notification-message ol {
                padding-left: 1.5rem;
                margin: 0.5rem 0;
            }

            .notification-message blockquote {
                margin: 0.5em 0; padding: 0.5em 1em;
                border-radius: 0;
                background-color: var(--sn-card-background);
                border-left: 4px solid var(--sn-card-border);
            }

            .notification-message a,
            .notification-title a {
                color: var(--sn-color-link);
                text-decoration: none;
            }

            .notification-message a:hover,
            .notification-title a:hover {
                text-decoration: underline;
                text-decoration-color: var(--sn-color-link-underline);
            }

            .dismiss-button {
                background: none;
                border: none;
                color: var(--sn-color-dismiss);
                cursor: pointer;
                padding: 0;
                margin-left: 10px;
                align-self: flex-start;
                transition: color var(--sn-animation-duration-fast) ease, transform var(--sn-animation-duration-medium) cubic-bezier(0.25, 0.1, 0.25, 1.5);
                width: var(--sn-icon-size);
                height: var(--sn-icon-size);
                display: inline-flex;
                align-items: center;
                justify-content: center;
            }

            .dismiss-button:hover {
                color: var(--sn-color-dismiss-hover);
                transform: rotate(90deg);
            }

            .dismiss-button:active {
                transform: rotate(90deg) scale(0.9);
            }

            .notification-buttons {
                margin-top: 12px;
                display: flex;
                gap: 8px;
                flex-wrap: wrap;
            }

            .notification-button {
                background-color: var(--sn-color-border);
                color: var(--sn-color-text-secondary);
                border: none;
                border-radius: var(--sn-border-radius-small);
                padding: 6px 12px;
                font-size: var(--sn-font-size-body);
                font-weight: 500;
                cursor: pointer;
                transition: transform var(--sn-animation-duration-fast) ease-in-out, box-shadow var(--sn-animation-duration-fast) ease-in-out, background-color var(--sn-animation-duration-fast) ease-in-out,filter var(--sn-animation-duration-fast) ease-in-out, color var(--sn-animation-duration-fast) ease-in-out;
            }
                
            .notification-button:hover {
                transform: translateY(-2px);
                box-shadow: var(--sn-shadow-button-hover);
                filter: brightness(1.15); 
            }

            .notification-button:not(.custom-bg):hover {
                background-color: var(--sn-button-hover-bg);
                color: var(--sn-button-hover-text);
                filter: brightness(1);
            }

            .notification-button:active {
                transform: translateY(0);
                box-shadow: none;
                transition-duration: 0.1s;
            }

            .notification-button.primary {
                background-color: var(--sn-color-link);
                color: #fff;
            }

            .notification-button.primary:hover {
                background-color: var(--sn-color-link);
                color: #fff;
                filter: brightness(1.1);
            }

            .notification-button.custom-bg:hover {
                filter: brightness(1.15);
            }

            .notification-message::-webkit-scrollbar {
                width: 6px;
            }

            .notification-message::-webkit-scrollbar-track {
                background: var(--sn-scrollbar-track);
                border-radius: 3px;
            }

            .notification-message::-webkit-scrollbar-thumb {
                background: var(--sn-scrollbar-thumb);
                border-radius: 3px;
            }

            .notification-message::-webkit-scrollbar-thumb:hover {
                background: var(--sn-scrollbar-thumb-hover);
            }

        `;
    }
}