GitHub Russian Translation

Translates GitHub websites into Russian

// ==UserScript==
// @name            GitHub Russian Translation
// @name:ru         Русификатор GitHub
// @author          Deflecta
// @contributionURL https://boosty.to/rushanm
// @description     Translates GitHub websites into Russian
// @description:ru  Переводит сайты GitHub на русский язык
// @grant           none
// @homepageURL     https://github.com/RushanM/GitHub-Russian-Translation
// @icon            https://github.githubassets.com/favicons/favicon.png
// @license         MIT
// @match           https://github.com/*
// @match           https://github.blog/*
// @match           https://education.github.com/*
// @run-at          document-end
// @namespace       githubrutraslation
// @supportURL      https://github.com/RushanM/GitHub-Russian-Translation/issues
// @version         1-B27
// ==/UserScript==

(function () {
    'use strict';

    const interFontLink = document.createElement('link');
    interFontLink.rel = 'stylesheet';
    interFontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap';
    document.head.appendChild(interFontLink);

    // Загружаем переводы из удалённого файла rus_p.json и объединяем все секции
    let translations = {};
    fetch("https://raw.githubusercontent.com/RushanM/GitHub-Russian-Translation/refs/heads/master/%D0%9E%D0%B1%D1%89%D0%B5%D0%B5/rus_p.json")
        .then(response => response.json())
        .then(data => {
            // Сохраняем перевод из dashboard для Chat with Copilot
            window.dashboardCopilotTranslation = data.dashboard["Chat with Copilot"];
            // Сохраняем перевод из dashboard для Home
            window.dashboardHomeTranslation = data.dashboard["Home"];
            translations = Object.assign(
                {},
                data.dashboard,
                data.search,
                data.left_sidebar,
                data.settings,
                data.repo_tabs,
                data.copilot,
                data.createnew,
                data.right_sidebar,
                data.copilot_openwith,
                data.general
            );
            runTranslation();
        });

    function runTranslation() {
        function getRepositoriesTranslation(count) {
            if (count === 1) return `${count} репозиторий`;
            if (count >= 2 && count <= 4) return `${count} репозитория`;
            return `${count} репозиториев`;
        }

        function formatStarCount() {
            const starCounters = document.querySelectorAll('.Counter.js-social-count');
            starCounters.forEach(counter => {
                let text = counter.textContent.trim();
                if (text.includes('k')) {
                    text = text.replace('.', ',').replace('k', 'К');
                    counter.textContent = text;
                }
            });
        }

        // Функция для перевода абсолютного времени во всплывающей подсказке, например:
        // «Feb 24, 2025, 3:09 PM GMT+3» → «24 февраля 2025, 15:09 по московскому времени»
        function translateAbsoluteTime(text) {
            // Маппирование месяцев берём из файла с переводами
            const monthMapping = translations.months || {
                Jan: 'января',
                Feb: 'февраля',
                Mar: 'марта',
                Apr: 'апреля',
                May: 'мая',
                Jun: 'июня',
                Jul: 'июля',
                Aug: 'августа',
                Sep: 'сентября',
                Oct: 'октября',
                Nov: 'ноября',
                Dec: 'декабря'
            };

            // Регулярное выражение для извлечения компонентов времени
            // Пример: Feb 24, 2025, 3:09 PM GMT+3
            const regex = /^([A-Z][a-z]{2}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2})\s*(AM|PM)\s*GMT\+3$/;
            const match = text.match(regex);
            if (match) {
                const monthEn = match[1];
                const day = match[2];
                const year = match[3];
                let hour = parseInt(match[4], 10);
                const minute = match[5];
                const period = match[6];

                // Преобразование в 24-часовой формат
                if (period === 'PM' && hour !== 12) {
                    hour += 12;
                } else if (period === 'AM' && hour === 12) {
                    hour = 0;
                }
                // Форматирование часов с ведущим нулём
                const hourStr = hour < 10 ? '0' + hour : hour.toString();
                const monthRu = monthMapping[monthEn] || monthEn;

                // Используем перевод из файла переводов
                const byMoscowTime = translations.time?.by_moscow_time || "по московскому времени";
                return `${day} ${monthRu} ${year}, ${hourStr}:${minute} ${byMoscowTime}`;
            }
            return text;
        }

        // Функция для перевода элементов <relative-time>
        function translateRelativeTimes() {
            const timeElements = document.querySelectorAll('relative-time');
            timeElements.forEach(el => {
                // Если элемент уже переведён в основном DOM, проверяем его теневой DOM
                if (el.getAttribute('data-translated')) {
                    // Проверяем наличие теневого корня и пытаемся перевести его содержимое
                    if (el.shadowRoot) {
                        // Получаем текстовые узлы из теневого корня
                        const shadowTextNodes = [];

                        // Функция для рекурсивного обхода теневого DOM
                        function collectTextNodes(node) {
                            if (node.nodeType === Node.TEXT_NODE) {
                                shadowTextNodes.push(node);
                            } else if (node.childNodes) {
                                node.childNodes.forEach(childNode => {
                                    collectTextNodes(childNode);
                                });
                            }
                        }
                        // Обходим теневой DOM
                        collectTextNodes(el.shadowRoot);

                        // Переводим каждый текстовый узел
                        shadowTextNodes.forEach(textNode => {
                            const originalText = textNode.textContent.trim();
                            // Ищем паттерны времени
                            const hoursAgoMatch = originalText.match(/(\d+)\s+hours?\s+ago/);
                            const minutesAgoMatch = originalText.match(/(\d+)\s+minutes?\s+ago/);
                            const daysAgoMatch = originalText.match(/(\d+)\s+days?\s+ago/);
                            const weeksAgoMatch = originalText.match(/(\d+)\s+weeks?\s+ago/);
                            const lastWeekMatch = originalText.match(/last\s+week/i);
                            const yesterdayMatch = originalText.match(/yesterday/i);
                            const dateMatch = originalText.match(/([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})\s+(\d{1,2}):(\d{2})/i);
                            const shortDateMatch = originalText.match(/on\s+([A-Za-z]{3})\s+(\d{1,2})/i);

                            if (hoursAgoMatch) {
                                const hours = parseInt(hoursAgoMatch[1], 10);
                                let translatedText;

                                // Правильное склонение
                                if (hours === 1) {
                                    translatedText = translations.time?.hour_singular || "1 час назад";
                                } else if (hours >= 2 && hours <= 4) {
                                    translatedText = (translations.time?.hour_few || "{count} часа назад").replace("{count}", hours);
                                } else {
                                    translatedText = (translations.time?.hour_many || "{count} часов назад").replace("{count}", hours);
                                }

                                textNode.textContent = textNode.textContent.replace(
                                    /(\d+)\s+hours?\s+ago/,
                                    translatedText
                                );
                            } else if (daysAgoMatch) {
                                const days = parseInt(daysAgoMatch[1], 10);
                                let translatedText;

                                // Правильное склонение
                                if (days === 1) {
                                    translatedText = translations.time?.day_singular || "1 день назад";
                                } else if (days >= 2 && days <= 4) {
                                    translatedText = (translations.time?.day_few || "{count} дня назад").replace("{count}", days);
                                } else {
                                    translatedText = (translations.time?.day_many || "{count} дней назад").replace("{count}", days);
                                }

                                textNode.textContent = textNode.textContent.replace(
                                    /(\d+)\s+days?\s+ago/,
                                    translatedText
                                );
                            } else if (weeksAgoMatch) {
                                const weeks = parseInt(weeksAgoMatch[1], 10);
                                let translatedText;

                                // Правильное склонение
                                if (weeks === 1) {
                                    translatedText = translations.time?.week_singular || "1 неделю назад";
                                } else if (weeks >= 2 && weeks <= 4) {
                                    translatedText = (translations.time?.week_few || "{count} недели назад").replace("{count}", weeks);
                                } else {
                                    translatedText = (translations.time?.week_many || "{count} недель назад").replace("{count}", weeks);
                                }

                                textNode.textContent = textNode.textContent.replace(
                                    /(\d+)\s+weeks?\s+ago/,
                                    translatedText
                                );
                            } else if (lastWeekMatch) {
                                textNode.textContent = textNode.textContent.replace(
                                    /last\s+week/i,
                                    translations.time?.last_week || "на прошлой неделе"
                                );
                            } else if (yesterdayMatch) {
                                textNode.textContent = textNode.textContent.replace(
                                    /yesterday/i,
                                    translations.time?.yesterday || "вчера"
                                );
                            } else if (shortDateMatch) {
                                // Обработка формата «on Mar 13»
                                const monthEn = shortDateMatch[1];
                                const day = shortDateMatch[2];

                                // Используем словарь месяцев из файла переводов
                                const monthRu = translations.months?.[monthEn] || monthEn;
                                const translatedDate = `${day} ${monthRu}`;

                                textNode.textContent = textNode.textContent.replace(
                                    /on\s+[A-Za-z]{3}\s+\d{1,2}/i,
                                    `${day} ${monthRu}`
                                );
                            } else if (dateMatch) {
                                // Если у нас полная дата в формате «April 11, 2025 10:27»
                                const monthEn = dateMatch[1];
                                const day = dateMatch[2];
                                const year = dateMatch[3];
                                const hour = dateMatch[4];
                                const minute = dateMatch[5];

                                // Используем словарь месяцев из файла переводов
                                const monthRu = translations.months?.[monthEn] || monthEn;
                                const translatedDate = `${day} ${monthRu} ${year} ${hour}:${minute}`;

                                textNode.textContent = textNode.textContent.replace(
                                    /[A-Za-z]+\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}/i,
                                    translatedDate
                                );
                            } else if (minutesAgoMatch) {
                                const minutes = parseInt(minutesAgoMatch[1], 10);
                                let translatedText;

                                // Правильное склонение
                                if (minutes === 1) {
                                    translatedText = translations.time?.minute_singular || "1 минуту назад";
                                } else if (minutes >= 2 && minutes <= 4) {
                                    translatedText = (translations.time?.minute_few || "{count} минуты назад").replace("{count}", minutes);
                                } else if (minutes >= 5 && minutes <= 20) {
                                    translatedText = (translations.time?.minute_many || "{count} минут назад").replace("{count}", minutes);
                                } else {
                                    // Для чисел 21, 31, 41… используем окончание как для 1
                                    const lastDigit = minutes % 10;
                                    const lastTwoDigits = minutes % 100;

                                    if (lastDigit === 1 && lastTwoDigits !== 11) {
                                        translatedText = (translations.time?.minute_singular || "1 минуту назад").replace("1", minutes);
                                    } else if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 10 || lastTwoDigits > 20)) {
                                        translatedText = (translations.time?.minute_few || "{count} минуты назад").replace("{count}", minutes);
                                    } else {
                                        translatedText = (translations.time?.minute_many || "{count} минут назад").replace("{count}", minutes);
                                    }
                                }

                                textNode.textContent = textNode.textContent.replace(
                                    /(\d+)\s+minutes?\s+ago/,
                                    translatedText
                                );
                            }
                        });
                    }
                    return;
                }

                // Перевод всплывающей подсказки, если атрибут title существует
                if (el.hasAttribute('title')) {
                    const originalTitle = el.getAttribute('title');
                    el.setAttribute('title', translateAbsoluteTime(originalTitle));
                }

                // Обработка текста даты/времени внутри элемента 
                const originalText = el.textContent.trim();

                // Обработка относительного времени «x hours ago», «x minutes ago» и т. д.
                const hoursAgoMatch = originalText.match(/(\d+)\s+hours?\s+ago/);
                const minutesAgoMatch = originalText.match(/(\d+)\s+minutes?\s+ago/);
                const onDateMatch = originalText.match(/on\s+([A-Za-z]+\s+\d+,\s+\d+)/);

                if (hoursAgoMatch) {
                    const hours = parseInt(hoursAgoMatch[1], 10);
                    let translatedText;

                    // Правильное склонение
                    if (hours === 1) {
                        translatedText = "1 час назад";
                    } else if (hours >= 2 && hours <= 4) {
                        translatedText = hours + " часа назад";
                    } else {
                        translatedText = hours + " часов назад";
                    }

                    el.textContent = translatedText;
                } else if (minutesAgoMatch) {
                    const minutes = parseInt(minutesAgoMatch[1], 10);
                    let translatedText;

                    // Правильное склонение
                    if (minutes === 1) {
                        translatedText = "1 минуту назад";
                    } else if (minutes >= 2 && minutes <= 4) {
                        translatedText = minutes + " минуты назад";
                    } else if (minutes >= 5 && minutes <= 20) {
                        translatedText = minutes + " минут назад";
                    } else {
                        // Для чисел 21, 31, 41… используем окончание как для 1
                        const lastDigit = minutes % 10;
                        const lastTwoDigits = minutes % 100;

                        if (lastDigit === 1 && lastTwoDigits !== 11) {
                            translatedText = minutes + " минуту назад";
                        } else if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 10 || lastTwoDigits > 20)) {
                            translatedText = minutes + " минуты назад";
                        } else {
                            translatedText = minutes + " минут назад";
                        }
                    }

                    el.textContent = translatedText;
                } else if (onDateMatch) {
                    // Обрабатываем формат «on Apr 12, 2025»
                    // Переводим английское название месяца на русский
                    const dateText = onDateMatch[1];
                    const monthMapping = {
                        Jan: 'января',
                        Feb: 'февраля',
                        Mar: 'марта',
                        Apr: 'апреля',
                        May: 'мая',
                        Jun: 'июня',
                        Jul: 'июля',
                        Aug: 'августа',
                        Sep: 'сентября',
                        Oct: 'октября',
                        Nov: 'ноября',
                        Dec: 'декабря'
                    };

                    // Регулярное выражение для поиска и замены месяца в строке даты
                    const monthRegex = /([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})/;
                    const dateMatch = dateText.match(monthRegex);

                    if (dateMatch) {
                        const monthEn = dateMatch[1];
                        const day = dateMatch[2];
                        const year = dateMatch[3];
                        const monthRu = monthMapping[monthEn] || monthEn;

                        el.textContent = `в ${day} ${monthRu} ${year}`;
                    } else {
                        el.textContent = "в " + dateText;
                    }
                }

                // Пробуем перехватывать события мутации теневого корня
                if (window.ShadowRoot && !el.getAttribute('data-shadow-observed')) {
                    try {
                        // Пытаемся получить доступ к теневому DOM
                        const shadowRoot = el.shadowRoot;
                        if (shadowRoot) {
                            // Добавляем наблюдатель за изменениями в теневом DOM
                            const shadowObserver = new MutationObserver((mutations) => {
                                mutations.forEach(mutation => {
                                    if (mutation.type === 'childList' || mutation.type === 'characterData') {
                                        // Проходим по всем текстовым узлам в теневом DOM
                                        const textNodes = [];
                                        const walk = document.createTreeWalker(shadowRoot, NodeFilter.SHOW_TEXT);

                                        let node;
                                        while (node = walk.nextNode()) {
                                            textNodes.push(node);
                                        }

                                        textNodes.forEach(textNode => {
                                            const originalText = textNode.textContent;
                                            // Ищем паттерны времени
                                            if (originalText.match(/(\d+)\s+hours?\s+ago/)) {
                                                const hours = parseInt(originalText.match(/(\d+)\s+hours?\s+ago/)[1], 10);
                                                let translatedText;
                                                // Правильное склонение
                                                if (hours === 1) {
                                                    translatedText = translations.time?.hour_singular || "1 час назад";
                                                } else if (hours >= 2 && hours <= 4) {
                                                    translatedText = (translations.time?.hour_few || "{count} часа назад").replace("{count}", hours);
                                                } else {
                                                    translatedText = (translations.time?.hour_many || "{count} часов назад").replace("{count}", hours);
                                                }

                                                textNode.textContent = translatedText;
                                            } else if (originalText.match(/(\d+)\s+days?\s+ago/)) {
                                                const days = parseInt(originalText.match(/(\d+)\s+days?\s+ago/)[1], 10);
                                                let translatedText;

                                                // Правильное склонение для русского языка
                                                if (days === 1) {
                                                    translatedText = translations.time?.day_singular || "1 день назад";
                                                } else if (days >= 2 && days <= 4) {
                                                    translatedText = (translations.time?.day_few || "{count} дня назад").replace("{count}", days);
                                                } else {
                                                    translatedText = (translations.time?.day_many || "{count} дней назад").replace("{count}", days);
                                                }

                                                textNode.textContent = translatedText;
                                            } else if (originalText.match(/yesterday/i)) {
                                                textNode.textContent = translations.time?.yesterday || "вчера";
                                            } else if (originalText.match(/([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})\s+(\d{1,2}):(\d{2})/i)) {
                                                const match = originalText.match(/([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})\s+(\d{1,2}):(\d{2})/i); const monthEn = match[1];
                                                const day = match[2];
                                                const year = match[3];
                                                const hour = match[4];
                                                const minute = match[5];

                                                // Используем словарь месяцев из файла переводов
                                                const monthRu = translations.months?.[monthEn] || monthEn;
                                                textNode.textContent = `${day} ${monthRu} ${year} ${hour}:${minute}`;
                                            } else if (originalText.match(/(\d+)\s+minutes?\s+ago/)) {
                                                const minutes = parseInt(originalText.match(/(\d+)\s+minutes?\s+ago/)[1], 10);
                                                let translatedText;

                                                // Правильное склонение
                                                if (minutes === 1) {
                                                    translatedText = translations.time?.minute_singular || "1 минуту назад";
                                                } else if (minutes >= 2 && minutes <= 4) {
                                                    translatedText = (translations.time?.minute_few || "{count} минуты назад").replace("{count}", minutes);
                                                } else {
                                                    translatedText = (translations.time?.minute_many || "{count} минут назад").replace("{count}", minutes);
                                                }

                                                textNode.textContent = translatedText;
                                            }
                                        });
                                    }
                                });
                            });

                            shadowObserver.observe(shadowRoot, {
                                childList: true,
                                characterData: true,
                                subtree: true
                            });

                            el.setAttribute('data-shadow-observed', 'true');
                        }
                    } catch (error) {
                        console.error('[Русификатор Гитхаба] Ошибка при работе с теневым DOM:', error);
                    }
                }

                // Отмечаем элемент как переведённый
                el.setAttribute('data-translated', 'true');
            });
        }

        // функция для проверки, находится ли элемент в контейнере, где перевод нежелателен
        function isExcludedElement(el) {
            // Если элемент находится внутри заголовков Markdown, то не переводим
            if (el.closest('.markdown-heading')) return true;
            // Если элемент находится внутри ячейки с названием каталога, то не переводим
            if (el.closest('.react-directory-filename-column')) return true;
            return false;
        }

        // Функция для перевода блока GitHub Education
        function translateEducationExperience() {
            document.querySelectorAll('.experience__action-item h3').forEach(el => {
                if (el.textContent.includes('Add') && el.textContent.includes('repositories to a list')) {
                    el.innerHTML = el.innerHTML.replace(
                        /Add .* repositories to a list/,
                        'Добавьте репозитории, на которые поставили звезду, в список'
                    );
                }
            });

            document.querySelectorAll('.experience__action-item p.mt-4.f4').forEach(el => {
                if (el.textContent.includes('To complete this task, create a list with at least 3')) {
                    el.innerHTML = el.innerHTML.replace(
                        /To complete this task, create a list with at least 3 .* repos with the list name 'My Repo Watchlist'./,
                        'Чтобы выполнить это задание, создайте список, содержащий не менее трёх репозиториев, на которые вы поставили звезду, с названием «My Repo Watchlist».'
                    );
                }
            });

            document.querySelectorAll('.experience__actions-items-labels .Label--attention').forEach(el => {
                if (el.textContent.trim() === 'Incomplete') {
                    el.textContent = 'Не выполнено';
                }
            });

            document.querySelectorAll('.experience__actions-items-labels .Label--secondary').forEach(el => {
                if (el.textContent.trim() === 'List') {
                    el.textContent = 'Список';
                }
            });

            document.querySelectorAll('.experience__cta .Button--primary .Button-label').forEach(el => {
                if (el.textContent.trim() === 'See detailed instructions') {
                    el.textContent = 'Подробные инструкции';
                }
            });

            document.querySelectorAll('.experience__cta .Button--secondary .Button-label').forEach(el => {
                if (el.textContent.trim() === 'Mark complete') {
                    el.textContent = 'Отметить как выполненное';
                }
            });
        }

        function translateTextContent() {
            const elements = document.querySelectorAll(
                '.ActionList-sectionDivider-title, .ActionListItem-label, span[data-content], ' +
                '.AppHeader-context-item-label, #qb-input-query, .Truncate-text, h2, button, ' +
                '.Label, a, img[alt], .Box-title, .post__content p, .post__content li, .Button-label, ' +
                '.prc-ActionList-GroupHeading-eahp0'
            );

            elements.forEach(el => {
                // Если элемент подпадает под исключения, пропускаем его
                if (isExcludedElement(el)) return;

                if (el.tagName === 'IMG' && el.alt.trim() in translations) {
                    el.alt = translations[el.alt.trim()];
                } else if (el.childElementCount === 0) {
                    const text = el.textContent.trim();
                    if (translations[text]) {
                        el.textContent = translations[text];
                    } else {
                        const match = text.match(/^(\d+) repositories$/);
                        if (match) {
                            const count = parseInt(match[1], 10);
                            el.textContent = getRepositoriesTranslation(count);
                        } else if (text === 'added a repository to') {
                            el.textContent = translations['added a repository to'];
                        }
                    }
                } else {
                    // Часть функции translateTextContent(), отвечающая за обработку элементов с дочерними элементами
                    if (el.childElementCount > 0) {
                        // Сборка текстового содержания с учётом дочерних элементов
                        let text = '';
                        el.childNodes.forEach(node => {
                            if (node.nodeType === Node.TEXT_NODE) {
                                text += node.textContent;
                            } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'KBD') {
                                text += '/'; // Добавление символа «/» из <kbd>
                            }
                        });
                        text = text.trim();
                        if (translations[text]) {
                            // Создание нового фрагмента с переводом и сохранение тега <kbd>, если перевод соответствует шаблону
                            const newFragment = document.createDocumentFragment();
                            const parts = translations[text].split('<kbd class="AppHeader-search-kbd">/</kbd>');
                            newFragment.append(document.createTextNode(parts[0]));
                            // Добавлять тег <kbd> только если есть вторая часть перевода
                            if (parts.length > 1 && parts[1] !== undefined) {
                                const kbd = document.createElement('kbd');
                                kbd.className = 'AppHeader-search-kbd';
                                kbd.textContent = '/';
                                newFragment.append(kbd);
                                newFragment.append(document.createTextNode(parts[1]));
                            }
                            // Очистка элемента и вставка нового контента
                            el.innerHTML = '';
                            el.appendChild(newFragment);
                        }
                        el.childNodes.forEach(node => {
                            if (node.nodeType === Node.TEXT_NODE) {
                                const originalText = node.textContent;
                                const trimmed = originalText.trim();
                                if (translations[trimmed]) {
                                    node.textContent = translations[trimmed];
                                } else if (originalText.includes("starred")) {
                                    node.textContent = originalText.replace("starred", translations["starred"]);
                                } else if (originalText.includes("added a repository to")) {
                                    node.textContent = originalText.replace("added a repository to", translations['added a repository to']);
                                } else if (originalText.includes("Notifications")) {
                                    node.textContent = originalText.replace("Notifications", translations['Notifications']);
                                }
                            }
                        });
                        // Последный выхват строчек
                        if (/\bstarred\b/.test(el.innerHTML) && !el.innerHTML.includes('starred-button-icon')) {
                            el.innerHTML = el.innerHTML.replace(/\bstarred\b/g, function (match) {
                                return translations["starred"];
                            });
                        }
                        if (/\badded a repository to\b/.test(el.innerHTML)) {
                            el.innerHTML = el.innerHTML.replace(/\badded a repository to\b/g, translations['added a repository to']);
                        }
                        if (/\bNotifications\b/.test(el.innerHTML)) {
                            el.innerHTML = el.innerHTML.replace(/\bNotifications\b/g, translations['Notifications']);
                        }
                    } else {
                        // Сначала каждый узел
                        el.childNodes.forEach(node => {
                            if (node.nodeType === Node.TEXT_NODE) {
                                let originalText = node.textContent;
                                // Переводы
                                originalText = originalText.replace(/\bstarred\b/g, translations["starred"]);
                                originalText = originalText.replace(/\badded a repository to\b/g, translations['added a repository to']);
                                originalText = originalText.replace(/\bNotifications\b/g, translations['Notifications']);
                                node.textContent = originalText;
                            }
                        });

                        // Если всё ещё остаётся, заменить внутренний HTML
                        if (/\bstarred\b/.test(el.innerHTML)) {
                            el.innerHTML = el.innerHTML.replace(/\bstarred\b/g, translations["starred"]);
                        }
                        if (/\badded a repository to\b/.test(el.innerHTML)) {
                            el.innerHTML = el.innerHTML.replace(/\badded a repository to\b/g, translations['added a repository to']);
                        }
                        if (/\bNotifications\b/.test(el.innerHTML)) {
                            el.innerHTML = el.innerHTML.replace(/\bNotifications\b/g, translations['Notifications']);
                        }
                    }
                }
            });
            formatStarCount();
            translateRelativeTimes();

            document.querySelectorAll('.Button-label').forEach(btn => {
                if (btn.textContent.trim() === "New") {
                    btn.textContent = "Создать";
                }
            });
        }

        function translateCopilotPreview() {
            const askCopilotPlaceholder = document.querySelector('.copilotPreview__input[placeholder="Ask Copilot"]');
            if (askCopilotPlaceholder && translations['Ask Copilot']) {
                askCopilotPlaceholder.setAttribute('placeholder', translations['Ask Copilot']);
            }
            document.querySelectorAll('.copilotPreview__suggestionButton div').forEach(div => {
                const text = div.textContent.trim();
                if (translations[text]) {
                    div.innerHTML = translations[text];
                }
            });
        }

        function translateAttributes() {
            // Перевод placeholder
            document.querySelectorAll('input[placeholder]').forEach(el => {
                const text = el.getAttribute('placeholder');
                if (translations[text]) {
                    el.setAttribute('placeholder', translations[text]);
                }
            });
            // Перевод aria-label
            document.querySelectorAll('[aria-label]').forEach(el => {
                const text = el.getAttribute('aria-label');
                if (translations[text]) {
                    el.setAttribute('aria-label', translations[text]);
                }
            });
        }

        function translateTooltips() {
            const copilotChatTooltip = document.querySelector('tool-tip[for="copilot-chat-header-button"]');
            if (copilotChatTooltip && copilotChatTooltip.textContent.trim() === 'Chat with Copilot') {
                // Используем перевод из dashboard, сохранённый ранее
                copilotChatTooltip.textContent = window.dashboardCopilotTranslation;
            }

            document.querySelectorAll('tool-tip[role="tooltip"]').forEach(tooltip => {
                const text = tooltip.textContent.trim();
                if (translations[text]) {
                    tooltip.textContent = translations[text];
                }
            });

            document.querySelectorAll('.prc-TooltipV2-Tooltip-cYMVY').forEach(tooltip => {
                const text = tooltip.textContent.trim();
                if (translations[text]) {
                    tooltip.textContent = translations[text];
                }
            });
        }

        function translateGitHubEducation() {
            const noticeForms = document.querySelectorAll('div.js-notice form.js-notice-dismiss');

            noticeForms.forEach(form => {
                const heading = form.querySelector('h3.h4');
                if (heading && heading.textContent.trim() === 'Learn. Collaborate. Grow.') {
                    heading.textContent = 'Учитесь. Кооперируйтесь. Развивайтесь.';
                }

                const desc = form.querySelector('p.my-3.text-small');
                if (desc && desc.textContent.includes('GitHub Education gives you the tools')) {
                    desc.textContent = 'GitHub Education предоставляет инструменты и поддержку сообщества, чтобы вы могли принимать технологические вызовы и превращать их в возможности. Ваше технологическое будущее начинается здесь!';
                }

                const link = form.querySelector('.Button-label');
                if (link && link.textContent.trim() === 'Go to GitHub Education') {
                    link.textContent = 'Перейти в GitHub Education';
                }
            });

            document.querySelectorAll('.h5.color-fg-on-emphasis.text-mono').forEach(el => {
                if (el.textContent.trim() === 'LaunchPad') {
                    el.textContent = translations['LaunchPad'];
                }
            });

            document.querySelectorAll('.experience__gradient.experience__title').forEach(el => {
                if (el.textContent.trim() === 'Intro to GitHub') {
                    el.textContent = translations['Intro to GitHub'];
                }
            });

            // Шрифт Inter
            const educationNavBlock = document.querySelector('.d-flex.flex-justify-center.flex-md-justify-start.pb-5.pb-sm-7');
            if (educationNavBlock) {
                educationNavBlock.style.fontFamily = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
            }

            document.querySelectorAll('header h1.mb-5').forEach(el => {
                el.style.fontFamily = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
            });
        }

        function translateExperienceHeaderItems() {
            // Перевод заголовков и текста в блоке experience__header__items
            document.querySelectorAll('.experience__header__item .experience__hero__heading').forEach(el => {
                const text = el.textContent.trim();
                if (translations[text]) {
                    el.textContent = translations[text];
                }
            });

            // Перевод основного текста в блоке experience__header__items
            document.querySelectorAll('.experience__header__item p.color-fg-on-emphasis').forEach(el => {
                const text = el.textContent.trim();
                if (translations[text]) {
                    el.textContent = translations[text];
                }
            });

            // Шрифт Inter ко всему блоку для лучшей поддержки кириллицы
            document.querySelectorAll('.color-fg-on-emphasis').forEach(el => {
                el.style.fontFamily = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
            });
        }

        function translateFilterMenu() {
            const filterTranslations = {
                "Filter": "Фильтр",
                "Events": "События",
                "Activity you want to see on your feed": "Деятельность, которую вы хотите видеть в своей ленте",
                "Announcements": "Объявления",
                "Special discussion posts from repositories": "Особые обсуждения из репозиториев",
                "Releases": "Выпуски",
                "Update posts from repositories": "Новые обновления в репозиториях",
                "Sponsors": "Спонсоры",
                "Relevant projects or people that are being sponsored": "Проекты или люди, которых кто-то начинает спонсировать",
                "Stars": "Звёзды",
                "Repositories being starred by people": "Репозитории, которые получают звёзды от людей",
                "Repositories": "Репозитории",
                "Repositories that are created or forked by people": "Репозитории, созданные или разветвлённые пользователями",
                "Repository activity": "Деятельность в репозиториях",
                "Issues and pull requests from repositories": "Новые темы и запросы на слияние в репозиториях",
                "Follows": "Подписки",
                "Who people are following": "На кого подписываются пользователи",
                "Recommendations": "Рекомендации",
                "Repositories and people you may like": "Репозитории и пользователи, которые могут вам понравиться",
                "Include events from starred repositories": "Включать события из репозиториев, на которые вы поставили звезду",
                "By default, the feed shows events from repositories you sponsor or watch, and people you follow.": "По умолчанию лента отображает события из репозиториев, которые вы спонсируете или за которыми следите, а также от людей, на которых подписаны.",
                "Reset to default": "Сбросить до настроек по умолчанию",
                "Save": "Сохранить"
            };

            const elements = document.querySelectorAll(
                '.SelectMenu-title, .SelectMenu-item h5, .SelectMenu-item span, .px-3.mt-2 h5, .px-3.mt-2 p'
            );

            elements.forEach(el => {
                const text = el.textContent.trim();
                if (filterTranslations[text]) {
                    el.textContent = filterTranslations[text];
                }
            });
        }

        function translateOpenCopilotMenu() {
            document.querySelectorAll('span.prc-ActionList-ItemLabel-TmBhn').forEach(el => {
                const originalText = el.textContent.trim();
                if (translations[originalText]) {
                    el.textContent = translations[originalText];
                }
            });
        }

        function translateStarButtons() {
            // Находим все span с классом d-inline внутри кнопок
            document.querySelectorAll('.BtnGroup-item .d-inline').forEach(span => {
                const text = span.textContent.trim();
                if (text === 'Star') {
                    span.textContent = translations["Star"] || 'Поставить звезду';
                } else if (text === 'Starred') {
                    span.textContent = translations["Starred"] || 'Звезда поставлена';
                }
            });

            // Переводим заголовок в диалоговом окне отмены звезды
            document.querySelectorAll('.Box-title').forEach(title => {
                if (title.textContent.trim() === 'Unstar this repository?') {
                    title.textContent = translations["Unstar this repository?"] || 'Убрать звезду с этого репозитория?';
                }
            });

            // Переводим кнопку Unstar в диалоговом окне
            document.querySelectorAll('.btn-danger.btn').forEach(btn => {
                if (btn.textContent.trim() === 'Unstar') {
                    btn.textContent = translations["Unstar"] || 'Убрать звезду';
                }
            });
        }

        function translateRepositoryButtons() {
            // Перевод кнопки Sponsor
            document.querySelectorAll('.Button-label .v-align-middle').forEach(span => {
                const text = span.textContent.trim();
                if (text === 'Sponsor') {
                    span.textContent = translations["Sponsor"] || 'Спонсировать';
                }
            });

            // Перевод кнопки Watch
            document.querySelectorAll('.prc-Button-Label-pTQ3x').forEach(span => {
                if (span.textContent.trim().startsWith('Watch')) {
                    // Сохраняем счётчик
                    const counter = span.querySelector('.Counter');
                    const counterHTML = counter ? counter.outerHTML : '';

                    // Новый текст с сохранённым счетчиком
                    span.innerHTML = (translations["Watch"] || 'Следить') +
                        (counterHTML ? ' ' + counterHTML : '');
                }
            });

            // Перевод кнопки Fork
            document.querySelectorAll('.BtnGroup.d-flex').forEach(btnGroup => {
                // Проверяем, что это непереведенная кнопка Fork
                if (btnGroup.textContent.includes('Fork') && !btnGroup.hasAttribute('data-translated-fork')) {
                    // Сначала сохраним все важные элементы
                    const counter = btnGroup.querySelector('#repo-network-counter');
                    const details = btnGroup.querySelector('details');

                    // Создаём функцию для глубокого обхода DOM-дерева
                    function translateNode(node) {
                        if (node.nodeType === Node.TEXT_NODE) {
                            // Регулярное выражение для поиска слова «Fork» с сохранением пробелов
                            const regex = /(\s*)Fork(\s*)/g;
                            node.textContent = node.textContent.replace(regex,
                                (match, before, after) => before + (translations["Fork"] || 'Разветвить') + after);
                        } else {
                            // Рекурсивный обход всех дочерних узлов
                            for (let i = 0; i < node.childNodes.length; i++) {
                                translateNode(node.childNodes[i]);
                            }
                        }
                    }

                    // Запускаем перевод с корневого элемента
                    translateNode(btnGroup);

                    // Отмечаем элемент как обработанный
                    btnGroup.setAttribute('data-translated-fork', 'true');
                }
            });
        }

        function translateLabelElements() {
            document.querySelectorAll('.prc-Label-Label--LG6X').forEach(el => {
                if (el.textContent.trim() === 'Free' && translations['Free']) {
                    el.textContent = translations['Free'];
                }
            });
        }

        // Функция для перевода статусов тем, кнопок редактирования и создания тем
        function translateIssueElements() {
            // Перевод статуса «Open» в темах
            document.querySelectorAll('span[data-testid="header-state"]').forEach(el => {
                if (el.textContent.trim() === 'Open' && translations['Open']) {
                    // Сохраняем SVG-значок
                    const svg = el.querySelector('svg');
                    const svgHTML = svg ? svg.outerHTML : '';

                    // Заменяем текст, сохраняя значок
                    el.innerHTML = svgHTML + translations['Open'];
                }
                // Перевод статуса «Closed» в темах
                else if (el.textContent.trim() === 'Closed' && translations['Closed']) {
                    // Сохраняем SVG-значок
                    const svg = el.querySelector('svg');
                    const svgHTML = svg ? svg.outerHTML : '';

                    // Заменяем текст, сохраняя значок
                    el.innerHTML = svgHTML + translations['Closed'];
                }
            });

            // Перевод кнопки «Edit»
            document.querySelectorAll('.prc-Button-ButtonBase-c50BI .prc-Button-Label-pTQ3x').forEach(el => {
                if (el.textContent.trim() === 'Edit' && translations['Edit']) {
                    el.textContent = translations['Edit'];
                }
            });

            // Перевод кнопки «New issue»
            document.querySelectorAll('.prc-Button-ButtonBase-c50BI .prc-Button-Label-pTQ3x').forEach(el => {
                if (el.textContent.trim() === 'New issue' && translations['New issue']) {
                    el.textContent = translations['New issue'];
                }
            });
            // Трансформация строки вида «Пользователь opened 2 hours ago» в «Открыта Пользователь 2 часа назад»
            document.querySelectorAll('.Box-sc-g0xbh4-0.dqmClk, [data-testid="issue-body-header-author"]').forEach(authorEl => {
                // Ищем ближайший родительский контейнер, который содержит также подвал с «opened»
                const container = authorEl.closest('.ActivityHeader-module__narrowViewportWrapper--Hjl75, .Box-sc-g0xbh4-0.koxHLL');
                if (!container) return;

                // Находим подвал с текстом «opened»
                const footer = container.querySelector('.ActivityHeader-module__footer--FVHp7, .Box-sc-g0xbh4-0.bJQcYY');
                if (!footer) return;

                // Находим span с «opened» и автором
                const openedSpan = footer.querySelector('span');
                const authorLink = authorEl.querySelector('a[data-testid="issue-body-header-author"], a[href*="/users/"]') || authorEl;

                // Проверяем, что span содержит «opened»
                if (!openedSpan || !openedSpan.textContent.includes('opened')) return;

                // Получаем ссылку на время с relative-time
                const timeLink = footer.querySelector('a[data-testid="issue-body-header-link"]');
                if (!timeLink) return;

                // Находим элемент relative-time внутри ссылки
                const relativeTime = timeLink.querySelector('relative-time');
                if (!relativeTime) return;

                try {
                    // Если уже трансформировано, пропускаем
                    if (footer.getAttribute('data-ru-transformed')) return;

                    // Отмечаем как трансформированное
                    footer.setAttribute('data-ru-transformed', 'true');

                    // Создаём новую структуру
                    // 1. Сохраняем автора
                    const authorClone = authorLink.cloneNode(true);

                    // 2. Меняем текст в span на перевод «opened» из файла локализации
                    openedSpan.textContent = translations["opened"] ? translations["opened"] + ' ' : 'Открыта ';

                    // 3. Вставляем автора после слова «Открыта»
                    openedSpan.after(authorClone);

                    // 4. Добавляем пробел между автором и временем
                    authorClone.after(document.createTextNode(' '));

                    // 5. Трансформируем текст времени
                    const originalTimeText = relativeTime.textContent;

                    // Проверяем, содержит ли текст паттерн времени (например, «3 hours ago» или «on Apr 12, 2025»)
                    const hoursAgoMatch = originalTimeText.match(/(\d+)\s+hours?\s+ago/);
                    const minutesAgoMatch = originalTimeText.match(/(\d+)\s+minutes?\s+ago/);
                    const onDateMatch = originalTimeText.match(/on\s+([A-Za-z]+\s+\d+,\s+\d+)/);

                    if (hoursAgoMatch) {
                        const hours = parseInt(hoursAgoMatch[1], 10);
                        let translatedText;

                        // Правильное склонение
                        if (hours === 1) {
                            translatedText = "Час назад";
                        } else if (hours >= 2 && hours <= 4) {
                            translatedText = hours + " часа назад";
                        } else {
                            translatedText = hours + " часов назад";
                        }

                        relativeTime.textContent = translatedText;
                    } else if (minutesAgoMatch) {
                        const minutes = parseInt(minutesAgoMatch[1], 10);
                        let translatedText;

                        // Правильное склонение
                        if (minutes === 1) {
                            translatedText = "1 минуту назад";
                        } else if (minutes >= 2 && minutes <= 4) {
                            translatedText = minutes + " минуты назад";
                        } else if (minutes >= 5 && minutes <= 20) {
                            translatedText = minutes + " минут назад";
                        } else {
                            // Для чисел 21, 31, 41… используем окончание как для 1
                            const lastDigit = minutes % 10;
                            const lastTwoDigits = minutes % 100;

                            if (lastDigit === 1 && lastTwoDigits !== 11) {
                                translatedText = minutes + " минуту назад";
                            } else if (lastDigit >= 2 && lastDigit <= 4 && (lastTwoDigits < 10 || lastTwoDigits > 20)) {
                                translatedText = minutes + " минуты назад";
                            } else {
                                translatedText = minutes + " минут назад";
                            }
                        }

                        relativeTime.textContent = translatedText;
                    } else if (onDateMatch) {
                        // Обрабатываем формат «on Apr 12, 2025»
                        // Переводим английское название месяца на русский
                        const dateText = onDateMatch[1];
                        const monthMapping = {
                            Jan: 'января',
                            Feb: 'февраля',
                            Mar: 'марта',
                            Apr: 'апреля',
                            May: 'мая',
                            Jun: 'июня',
                            Jul: 'июля',
                            Aug: 'августа',
                            Sep: 'сентября',
                            Oct: 'октября',
                            Nov: 'ноября',
                            Dec: 'декабря'
                        };

                        // Регулярное выражение для поиска и замены месяца в строке даты
                        const monthRegex = /([A-Za-z]{3})\s+(\d{1,2}),\s+(\d{4})/;
                        const dateMatch = dateText.match(monthRegex);

                        if (dateMatch) {
                            const monthEn = dateMatch[1];
                            const day = dateMatch[2];
                            const year = dateMatch[3];
                            const monthRu = monthMapping[monthEn] || monthEn;

                            relativeTime.textContent = `в ${day} ${monthRu} ${year}`;
                        } else {
                            relativeTime.textContent = "в " + dateText;
                        }
                    }

                    // 6. Скрываем оригинальный контейнер с автором
                    if (authorEl !== authorLink) {
                        authorEl.style.cssText = 'display: none !important;';
                    }

                    console.log('[Русификатор Гитхаба] Cтрока с автором темы трансформирована');
                } catch (error) {
                    console.error('[Русификатор Гитхаба] Ошибка при трансформации строки с автором:', error);
                }
            });
        }

        const feedTitleEl = document.querySelector('[data-target="feed-container.feedTitle"]');
        if (feedTitleEl && window.dashboardHomeTranslation) {
            feedTitleEl.textContent = window.dashboardHomeTranslation;
        }

        document.querySelectorAll('#feed-filter-menu summary').forEach(summary => {
            summary.innerHTML = summary.innerHTML.replace('Filter', translations["Filter"]);
        });

        const observer = new MutationObserver(() => {
            translateTextContent();
            translateAttributes();
            translateCopilotPreview();
            translateTooltips();
            translateGitHubEducation();
            translateExperienceHeaderItems();
            translateEducationExperience();
            translateFilterMenu();
            translateOpenCopilotMenu();
            translateStarButtons();
            translateRepositoryButtons();
            translateLabelElements();
            translateIssueElements();

            // Перевод подвала
            document.querySelectorAll('p.color-fg-subtle.text-small.text-light').forEach(node => {
                if (node.textContent.trim() === '© 2025 GitHub, Inc.') {
                    node.textContent = translations['© 2025 GitHub, Inc.'];
                }
            });

            document.querySelectorAll('a.Link').forEach(link => {
                const text = link.textContent.trim();
                if (text === 'About') link.textContent = 'О нас';
                if (text === 'Blog') link.textContent = 'Блог';
                if (text === 'Terms') link.textContent = 'Условия';
                if (text === 'Privacy') link.textContent = 'Конфиденциальность';
                if (text === 'Security') link.textContent = 'Безопасность';
                if (text === 'Status') link.textContent = 'Статус';
            });

            document.querySelectorAll('.Button-label').forEach(btn => {
                if (btn.textContent.trim() === 'Do not share my personal information') {
                    btn.textContent = 'Не передавать мои личные данные';
                }
                if (btn.textContent.trim() === 'Manage Cookies') {
                    btn.textContent = 'Управление куки';
                }
            });

            // Владельцы и перейти
            document.querySelectorAll('h3.ActionList-sectionDivider-title').forEach(node => {
                if (node.textContent.trim() === 'Owners') {
                    node.textContent = translations['Owners'];
                }
            });

            document.querySelectorAll('.ActionListItem-description.QueryBuilder-ListItem-trailing').forEach(span => {
                if (span.textContent.trim() === 'Jump to') {
                    span.textContent = translations['Jump to'];
                }
            });

            // Перевод для кнопки «Chat with Copilot»
            document.querySelectorAll('.ActionListItem-label').forEach(el => {
                if (el.textContent.trim() === "Chat with Copilot" && translations["Chat with Copilot"]) {
                    el.textContent = translations["Chat with Copilot"];
                }
            });

            // Перевод описания «Start a new Copilot thread»
            document.querySelectorAll('.QueryBuilder-ListItem-trailing').forEach(el => {
                if (el.textContent.trim() === "Start a new Copilot thread" && translations["Start a new Copilot thread"]) {
                    el.textContent = translations["Start a new Copilot thread"];
                }
            });

            // Замена «added a repository to»
            document.querySelectorAll('h3.h5.text-normal.color-fg-muted.d-flex.flex-items-center.flex-row.flex-nowrap.width-fit span.flex-1 span.flex-shrink-0').forEach(span => {
                if (span.textContent.trim() === 'added a repository to') {
                    span.textContent = translations['added a repository to'];
                }
            });
        });

        // Наблюдение за всем документом, включая изменения атрибутов
        observer.observe(document, {
            childList: true,
            subtree: true,
            attributes: true
        });

        // Будущие изменения
        const observerStarred = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => replaceAllStarred(node));
            });
        });

        // Запуск
        observerStarred.observe(document.body, { childList: true, subtree: true });

        // Начальное прохождение
        replaceAllStarred(document.body);

        translateTextContent();
        translateAttributes();
        translateCopilotPreview();
        translateTooltips();
        translateGitHubEducation();
        translateExperienceHeaderItems();
        translateEducationExperience();
        translateFilterMenu();
        translateOpenCopilotMenu();
        translateStarButtons();
        translateRepositoryButtons();
        translateIssueElements();

        // Замена «Filter»
        document.querySelectorAll('summary .octicon-filter').forEach(icon => {
            const summary = icon.parentElement;
            if (summary) {
                summary.childNodes.forEach(node => {
                    if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === 'Filter') {
                        node.textContent = translations["Filter"];
                    }
                });
            }
        });

        // Добавляем текст «Фильтр» в кнопку, если его нет
        document.querySelectorAll('summary').forEach(summary => {
            if (summary.querySelector('.octicon-filter')) {
                let hasFilterText = false;
                summary.childNodes.forEach(node => {
                    if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === 'Фильтр') {
                        hasFilterText = true;
                    }
                });
                if (!hasFilterText) {
                    const svgIcon = summary.querySelector('.octicon-filter');
                    const textNode = document.createTextNode('Фильтр');
                    if (svgIcon && svgIcon.nextSibling) {
                        summary.insertBefore(textNode, svgIcon.nextSibling);
                    } else {
                        summary.appendChild(textNode);
                    }
                }
            }
        });

        translateFilterMenu();
    }
})();