- // ==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();
- }
- })();