From b7fbc9c90eb4d8aff398cb2b0c75efbbbe7b12d0 Mon Sep 17 00:00:00 2001 From: pechenka128 Date: Fri, 23 Oct 2026 06:02:45 +0300 Subject: [PATCH] Add localization support and Russian translation (AI assisted) - Add a small i18n layer (js/i18n.js): English text is the lookup key, so missing translations fall back to English and English output is unchanged. - Add Russian locale (js/locales/ru.js): UI text, tasks.json/cycles.json data, and the "More Info" dialogs. Game terms follow the official Russian game localization. - Add a language selector to the options menu; the choice is saved in localStorage and defaults to the browser language. - Add the Cyrillic subset of the self-hosted Inter font. - Format dates and countdown units per language. - Add tests for the i18n helpers. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GK1MKT52EY5AJazPdgkuTV --- README.md | 9 + sources/css/critical.css | 34 ++ sources/css/style.css | 12 + sources/fonts/inter-cyrillic-500.woff2 | Bin 0 -> 7900 bytes sources/fonts/inter-cyrillic-600.woff2 | Bin 0 -> 7972 bytes sources/fonts/inter-cyrillic-700.woff2 | Bin 0 -> 7904 bytes sources/fonts/inter-cyrillic-regular.woff2 | Bin 0 -> 7712 bytes sources/index.html | 4 + sources/js/app.js | 165 ++++---- sources/js/constants.js | 5 +- sources/js/functions.js | 19 +- sources/js/i18n.js | 146 +++++++ sources/js/locales/ru.js | 453 +++++++++++++++++++++ sources/js/moreInfo.js | 6 +- sources/tests/i18n.test.js | 91 +++++ 15 files changed, 866 insertions(+), 78 deletions(-) create mode 100644 sources/fonts/inter-cyrillic-500.woff2 create mode 100644 sources/fonts/inter-cyrillic-600.woff2 create mode 100644 sources/fonts/inter-cyrillic-700.woff2 create mode 100644 sources/fonts/inter-cyrillic-regular.woff2 create mode 100644 sources/js/i18n.js create mode 100644 sources/js/locales/ru.js create mode 100644 sources/tests/i18n.test.js diff --git a/README.md b/README.md index 8eb0c6d..ef257c1 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,12 @@ This is an unofficial fan-made tool. Warframe and all related assets are the int ## License This project is licensed under the **GNU General Public License v3.0 (GPLv3)**. See the [LICENSE](https://github.com/warframe-tools/Task-Checklist?tab=GPL-3.0-1-ov-file) file for details. + +## Languages + +The interface is available in English (default) and Russian. The language is picked from the browser settings on first +visit and can be changed in the options menu (☰). English text is used as the lookup key, so anything without a +translation simply shows in English. + +To add a language, create `sources/js/locales/.js` (use `ru.js` as a template), then register it in +`LOCALES` and `LANGUAGE_NAMES` in `sources/js/i18n.js`. Use the game's official localization for in-game terms. diff --git a/sources/css/critical.css b/sources/css/critical.css index b29be6f..7bec97d 100644 --- a/sources/css/critical.css +++ b/sources/css/critical.css @@ -28,6 +28,40 @@ src: url("/fonts/inter-v18-latin-700.woff2") format("woff2"); } +/* Cyrillic subset (used by the Russian translation) */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/inter-cyrillic-regular.woff2") format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/inter-cyrillic-500.woff2") format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/inter-cyrillic-600.woff2") format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/inter-cyrillic-700.woff2") format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + /* --- Global Theme Variables (Critical for initial paint) --- */ :root { /* Default: Dark Mode */ diff --git a/sources/css/style.css b/sources/css/style.css index 204e353..8eb71fa 100644 --- a/sources/css/style.css +++ b/sources/css/style.css @@ -325,6 +325,18 @@ input[type="checkbox"]:indeterminate::after { display: flex; align-items: start; } +#options-menu .language-option { + display: flex; + align-items: center; + gap: 0.5rem; +} +#language-select { + color: var(--text-label); + background-color: var(--bg-content); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; +} /* Dialogs */ dialog { diff --git a/sources/fonts/inter-cyrillic-500.woff2 b/sources/fonts/inter-cyrillic-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..dd14bcf0e1647ca165c4dcecf36a707bb14a7558 GIT binary patch literal 7900 zcmV<29wXs*Pew8T0RR9103O@`5C8xG06}B`03Le)0RR9100000000000000000000 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 GuLS_PO$vGd literal 0 HcmV?d00001 diff --git a/sources/fonts/inter-cyrillic-600.woff2 b/sources/fonts/inter-cyrillic-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e250ef0c51d32532938cb63aaeceff7c97dda4d1 GIT binary patch literal 7972 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 atg_R6^K_keup{1>@KofV^8eZ+)CB;DrhqO0 literal 0 HcmV?d00001 diff --git a/sources/fonts/inter-cyrillic-700.woff2 b/sources/fonts/inter-cyrillic-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..781e4b4d96733a950812b1459fb5a9016206d54d GIT binary patch literal 7904 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 +


diff --git a/sources/js/app.js b/sources/js/app.js index 59659b3..c355694 100644 --- a/sources/js/app.js +++ b/sources/js/app.js @@ -18,8 +18,11 @@ import { makeInfoLineItem, calcTaskTimes, makeSectionStats, + formatDateLong, } from "./functions.js"; +import { t as tr, tc, getLanguage, saveLanguage, localizeData, translateDocument, LANGUAGE_NAMES } from "./i18n.js"; + import * as C from "./constants.js"; import * as svgIcons from "./icons.js"; @@ -49,6 +52,8 @@ import tasks from "./tasks.json" with { type: "json" }; import cycles from "./cycles.json" with { type: "json" }; import moreInfo from "./moreInfo.js"; +localizeData(tasks, cycles); + // --- DOM Elements (defined after DOMContentLoaded) --- let bodyElement, themeToggleButton, hamburgerButton, optionsMenu, resetDailyButton, resetWeeklyButton, resetButton, unhideTasksButton, lastSavedTimestampElement, saveStatusElement, sectionToggles, dailyResetTimeElement, @@ -170,37 +175,37 @@ function displayError(message) { console.error("Displaying Error:", message); errorMessageElement.textContent = message; errorDisplayElement.classList.add("visible"); - if (errorCopyButton) { errorCopyButton.textContent = "Copy"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copy"); } } function hideError() { if (!errorDisplayElement) { return; } errorDisplayElement.classList.remove("visible"); errorMessageElement.textContent = ""; - if (errorCopyButton) { errorCopyButton.textContent = "Copy"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copy"); } } function copyErrorToClipboard() { const errorMessage = errorMessageElement.textContent; if (!errorMessage || !navigator.clipboard) { console.warn("Clipboard API not available or no error message to copy."); - if (errorCopyButton) { errorCopyButton.textContent = "Failed"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Failed"); } setTimeout(() => { - if (errorCopyButton) { errorCopyButton.textContent = "Copy"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copy"); } }, 2000); return; } navigator.clipboard.writeText(errorMessage).then(() => { console.log("Error message copied to clipboard."); - if (errorCopyButton) { errorCopyButton.textContent = "Copied!"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copied!"); } setTimeout(() => { - if (errorCopyButton) { errorCopyButton.textContent = "Copy"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copy"); } }, 2000); }).catch((err) => { console.error("Failed to copy error message: ", err); - if (errorCopyButton) { errorCopyButton.textContent = "Failed"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Failed"); } setTimeout(() => { - if (errorCopyButton) { errorCopyButton.textContent = "Copy"; } + if (errorCopyButton) { errorCopyButton.textContent = tr("Copy"); } }, 2000); }); } @@ -223,7 +228,7 @@ function handleThemeToggle() { localStorage.setItem(THEME_STORAGE_KEY, newTheme); } catch (e) { console.error("Could not save theme preference:", e); - displayError("Could not save theme preference."); + displayError(tr("Could not save theme preference.")); } } @@ -250,7 +255,7 @@ function loadThemePreference() { function updateLastSavedDisplay(timestamp) { if (lastSavedTimestampElement) { - lastSavedTimestampElement.textContent = `Last saved: ${formatTimestamp(timestamp)}`; + lastSavedTimestampElement.textContent = tr("Last saved: {time}", { time: formatTimestamp(timestamp) }); } else { console.error("lastSavedTimestampElement not found"); } @@ -299,7 +304,7 @@ function displayLocalResetTimes() { const nextDailyResetTimestamp = getNextDailyMidnightUTC(); const dailyDiff = nextDailyResetTimestamp - now; if (dailyResetTimeElement) { - dailyResetTimeElement.innerHTML = `(Resets in ${formatCountdown(dailyDiff)})`; + dailyResetTimeElement.innerHTML = tr("(Resets in {time})", { time: `${formatCountdown(dailyDiff)}` }); } // Weekly @@ -309,7 +314,7 @@ function displayLocalResetTimes() { } const weeklyDiff = nextWeeklyResetTimestamp - now; if (weeklyResetTimeElement) { - weeklyResetTimeElement.innerHTML = `(Resets in ${formatCountdown(weeklyDiff)})`; + weeklyResetTimeElement.innerHTML = tr("(Resets in {time})", { time: `${formatCountdown(weeklyDiff)}` }); } // Other @@ -318,8 +323,8 @@ function displayLocalResetTimes() { tasks.other.forEach((task) => displayOtherTaskCountdown(task)); } catch (e) { console.error("Error calculating or displaying local reset times:", e); - if (dailyResetTimeElement) { dailyResetTimeElement.innerHTML = "(Resets 00:00 UTC)"; } - if (weeklyResetTimeElement) { weeklyResetTimeElement.innerHTML = "(Resets Mon 00:00 UTC)"; } + if (dailyResetTimeElement) { dailyResetTimeElement.innerHTML = tr("(Resets 00:00 UTC)"); } + if (weeklyResetTimeElement) { weeklyResetTimeElement.innerHTML = tr("(Resets Mon 00:00 UTC)"); } } } @@ -336,20 +341,20 @@ export function displayOtherTaskCountdown(task) { if (taskTimes.isAvailable) { const diff = taskTimes.thisCycleLeaveTimestamp - now.getTime(); - resetTimer.innerHTML = `(Available for ${formatCountdown(diff)})`; + resetTimer.innerHTML = tr("(Available for {time})", { time: `${formatCountdown(diff)}` }); // Leaving soon notification (Arrival notification is handled in otherTaskReset, the same as always available tasks) if (diff < C.MILLISECONDS_PER_HOUR && checklistData.notificationPreferences[task.id] && checklistData.notificationsSent[leaveNotifId] !== resetCount) { - showNotification(`${task.title} Leaving Soon!`, `Approximately ${Math.round(diff / C.MILLISECONDS_PER_MINUTE)} minutes remaining.`); + showNotification(tr("{title} Leaving Soon!", { title: task.title }), tr("Approximately {minutes} minutes remaining.", { minutes: Math.round(diff / C.MILLISECONDS_PER_MINUTE) })); checklistData.notificationsSent[leaveNotifId] = resetCount; saveData(false); } } else { // task not available - resetTimer.innerHTML = `(Available in ${formatCountdown(taskTimes.nextResetTimestamp - now.getTime())})`; + resetTimer.innerHTML = tr("(Available in {time})", { time: `${formatCountdown(taskTimes.nextResetTimestamp - now.getTime())}` }); if (checklistData.notificationsSent[leaveNotifId]) { delete checklistData.notificationsSent[leaveNotifId]; } } } else { // always available task - resetTimer.innerHTML = `(Resets in ${formatCountdown(taskTimes.nextResetTimestamp - now.getTime())})`; + resetTimer.innerHTML = tr("(Resets in {time})", { time: `${formatCountdown(taskTimes.nextResetTimestamp - now.getTime())}` }); } } @@ -455,7 +460,7 @@ function otherTaskReset(task) { // Send and record new notification if (checklistData.notificationPreferences[task.id] && checklistData.notificationsSent[task.id] !== resetCount) { - showNotification(`${task.title} has reset!`, "Vendor stock may have updated."); + showNotification(tr("{title} has reset!", { title: task.title }), tr("Vendor stock may have updated.")); checklistData.notificationsSent[task.id] = resetCount; saveData(false); } @@ -467,7 +472,7 @@ function otherTaskReset(task) { async function requestNotificationPermission() { if (!("Notification" in window)) { console.warn("This browser does not support desktop notification"); - alert("This browser does not support desktop notifications."); + alert(tr("This browser does not support desktop notifications.")); return false; } if (Notification.permission === "granted") { @@ -478,11 +483,11 @@ async function requestNotificationPermission() { if (permission === "granted") { return true; } else { - alert("Notification permission was denied. You can enable it in your browser settings."); + alert(tr("Notification permission was denied. You can enable it in your browser settings.")); return false; } } else { - alert("Notification permission has been denied. Please enable it in your browser settings if you wish to receive notifications."); + alert(tr("Notification permission has been denied. Please enable it in your browser settings if you wish to receive notifications.")); return false; } } @@ -553,7 +558,7 @@ function createChecklistItem(task) { /* eslint-disable-line complexity, max-line if (task.ref) { const resetTimer = document.createElement("span"); resetTimer.classList.add("other-countdown"); - resetTimer.textContent = "(Loading...)"; + resetTimer.textContent = tr("(Loading...)"); taskDescription.appendChild(resetTimer); } @@ -567,8 +572,8 @@ function createChecklistItem(task) { /* eslint-disable-line complexity, max-line if (task.id.startsWith("other_")) { const notificationButton = document.createElement("button"); notificationButton.classList.add("notification-toggle-btn", "task-ctrl-btn"); - notificationButton.setAttribute("aria-label", `Toggle notifications for ${task.title}`); - notificationButton.title = `Toggle notifications for ${task.title}`; + notificationButton.setAttribute("aria-label", tr("Toggle notifications for {title}", { title: task.title })); + notificationButton.title = tr("Toggle notifications for {title}", { title: task.title }); notificationButton.innerHTML = svgIcons.bellIcon; if (checklistData.notificationPreferences[task.id]) { notificationButton.classList.add("active"); } @@ -591,12 +596,12 @@ function createChecklistItem(task) { /* eslint-disable-line complexity, max-line skipButton.classList.add("task-ctrl-btn"); let skipTooltip; if (isAvailable) { - let when = "this cycle"; - if (task.id.startsWith("daily_")) { when = "today"; } /* eslint-disable-line @stylistic/brace-style */ - else if (task.id.startsWith("weekly_")) { when = "this week"; } - skipTooltip = `Skip task ${when}: ${task.title}`; + let when = tr("this cycle"); + if (task.id.startsWith("daily_")) { when = tr("today"); } /* eslint-disable-line @stylistic/brace-style */ + else if (task.id.startsWith("weekly_")) { when = tr("this week"); } + skipTooltip = tr("Skip task {when}: {title}", { when, title: task.title }); } else { - skipTooltip = `Hide task until it becomes available: ${task.title}`; + skipTooltip = tr("Hide task until it becomes available: {title}", { title: task.title }); } skipButton.title = skipButton.ariaLabel = skipTooltip; skipButton.innerHTML = svgIcons.skipIcon; @@ -606,8 +611,8 @@ function createChecklistItem(task) { /* eslint-disable-line complexity, max-line // Hide Button const hideButton = document.createElement("button"); hideButton.classList.add("task-ctrl-btn"); - hideButton.setAttribute("aria-label", `Hide task: ${task.title}`); - hideButton.title = `Hide task: ${task.title}`; + hideButton.setAttribute("aria-label", tr("Hide task: {title}", { title: task.title })); + hideButton.title = tr("Hide task: {title}", { title: task.title }); hideButton.innerHTML = svgIcons.hideIcon; hideButton.addEventListener("click", hideOrSkipTaskAction(task, false)); controlsContainer.appendChild(hideButton); @@ -770,7 +775,7 @@ function updateIncompleteSubtaskCount(task, queryFrom = document) { const unchecked = queryFrom.querySelectorAll(`input[data-parent-id="${task.id}"]:not(:checked, :indeterminate, .hidden-task *)`); element.innerText = unchecked.length; element.parentElement.dataset.count = unchecked.length; - element.parentElement.title = `${unchecked.length} incomplete subtasks of ${task.title}`; + element.parentElement.title = tr("{count} incomplete subtasks of {title}", { count: unchecked.length, title: task.title }); } } @@ -797,7 +802,7 @@ function showScheduleAction(task, cycleIndex, isAvailable) { thead.innerHTML = ""; tbody.innerHTML = ""; - let header = "Date"; + let header = `${tr("Date")}`; for (const column of cycles[task.id].columns) { header += `${column.name}`; } @@ -813,7 +818,7 @@ function showScheduleAction(task, cycleIndex, isAvailable) { const currentCycleStartTime = ref.getTime() + (period * cyclesSinceRef); for (let i = 0; i <= cycleCount; i++) { - let date = "Now"; + let date = tr("Now"); if (i !== 0 || !isAvailable) { const rowCycleStartDate = new Date(currentCycleStartTime + (period * i)); date = rowCycleStartDate.toISOString().split("T")[0]; // toISOString is always in UTC, which is what we want. The part before "T" is the date @@ -821,7 +826,7 @@ function showScheduleAction(task, cycleIndex, isAvailable) { let repeat = ""; if (i === cycleCount) { - repeat = `(Cycle Repeats)`; + repeat = `${tr("(Cycle Repeats)")}`; } let row = `${repeat}${date}`; // intermediate `row` variable is needed because manipulating `tbody.innerHTML` automatically adds `` tag @@ -870,12 +875,12 @@ function makeInfoLine(task, appendTo) { /* eslint-disable-line max-lines-per-fun let prefix; const isAvailable = calcTaskTimes(task, now).isAvailable; if (task.section === "weekly") { - prefix = "This Week"; + prefix = tr("This Week"); } else if (task.section === "daily") { - prefix = "Today"; + prefix = tr("Today"); } else { - prefix = "Current Cycle"; - if (!isAvailable) { prefix = "Next Cycle"; } + prefix = tr("Current Cycle"); + if (!isAvailable) { prefix = tr("Next Cycle"); } } const resetCount = calcResetCount(task, now, ref); // calcResetCount handles DST @@ -897,7 +902,7 @@ function makeInfoLine(task, appendTo) { /* eslint-disable-line max-lines-per-fun const showSchedule = document.createElement("button"); showSchedule.type = "button"; showSchedule.classList.add("more-info-btn"); - showSchedule.innerHTML = "Show Schedule"; + showSchedule.innerHTML = tr("Show Schedule"); showSchedule.addEventListener("click", showScheduleAction(task, cycleIndex, isAvailable)); currentCycle.appendChild(showSchedule); @@ -910,19 +915,19 @@ function makeInfoLine(task, appendTo) { /* eslint-disable-line max-lines-per-fun const infoLine = document.createElement("div"); infoLine.classList.add("info-line"); let infoLineHTML = ""; - infoLineHTML += makeInfoLineItem(task, "location", "Location", svgIcons.locationIcon); - infoLineHTML = infoLineHTML.replace("Base of Operations", C.BASE_OF_OPERATIONS_TOOLTIP); - infoLineHTML += makeInfoLineItem(task, "npc", "NPC", svgIcons.npcIcon); - infoLineHTML += makeInfoLineItem(task, "terminal", "Terminal", svgIcons.terminalIcon); - infoLineHTML += makeInfoLineItem(task, "prereq", "Requirements", svgIcons.prereqIcon); - infoLineHTML += makeInfoLineItem(task, "info", "Info", svgIcons.infoIcon); + infoLineHTML += makeInfoLineItem(task, "location", tr("Location"), svgIcons.locationIcon); + infoLineHTML = infoLineHTML.replace(C.BASE_OF_OPERATIONS_NAME, C.BASE_OF_OPERATIONS_TOOLTIP); + infoLineHTML += makeInfoLineItem(task, "npc", tr("NPC"), svgIcons.npcIcon); + infoLineHTML += makeInfoLineItem(task, "terminal", tr("Terminal"), svgIcons.terminalIcon); + infoLineHTML += makeInfoLineItem(task, "prereq", tr("Requirements"), svgIcons.prereqIcon); + infoLineHTML += makeInfoLineItem(task, "info", tr("Info"), svgIcons.infoIcon); infoLine.innerHTML = infoLineHTML; if (task.moreInfo) { const showMoreInfo = document.createElement("button"); showMoreInfo.type = "button"; showMoreInfo.classList.add("more-info-btn"); - showMoreInfo.innerHTML = "More Info"; + showMoreInfo.innerHTML = tr("More Info"); showMoreInfo.addEventListener("click", showMoreInfoAction(task)); const span = document.createElement("span"); span.appendChild(showMoreInfo); @@ -1002,7 +1007,7 @@ function calcSectionStats(parent, queryFrom = document) { statButton.type = "button"; statButton.classList.add(`stats-${stat}`); statButton.dataset.count = stats[stat]; - statButton.innerHTML = `${statIcons[stat]} +${stats[stat]} ${C.TASKLIST_STAT_NAMES[stat]}`; + statButton.innerHTML = `${statIcons[stat]} +${stats[stat]} ${tr(C.TASKLIST_STAT_NAMES[stat])}`; statButton.addEventListener("click", showHiddenTasksAction(parent, taskList, stat)); statsBox.appendChild(statButton); } @@ -1016,12 +1021,12 @@ function showHiddenTasksAction(parent, taskList, stat) { if (parent.includes("_")) { // subtask list const parentTask = getTaskById(parent); taskDialogHeaderSetup(parentTask, hiddenTasksDialog); - prefix.innerText = `${C.TASKLIST_STAT_NAMES[stat]} subtasks of `; + prefix.innerText = tc("list", "{stat} subtasks of ", { stat: tc("list", C.TASKLIST_STAT_NAMES[stat]) }); suffix.innerText = ""; } else { // section list - taskDialogHeaderSetup({ title: C.SECTION_NAMES[parent] }, hiddenTasksDialog); // use a fake task to set the task title to the section name - prefix.innerText = `${C.TASKLIST_STAT_NAMES[stat]} `; - suffix.innerText = " Tasks"; + taskDialogHeaderSetup({ title: tc("list", C.SECTION_NAMES[parent]) }, hiddenTasksDialog); // use a fake task to set the task title to the section name + prefix.innerText = `${tc("list", C.TASKLIST_STAT_NAMES[stat])} `; + suffix.innerText = tc("list", " Tasks"); } // Task List @@ -1047,13 +1052,13 @@ function showHiddenTasksAction(parent, taskList, stat) { } if (stat === "completed") { - controlButton.title = controlButton.ariaLabel = `Uncheck task: ${task.title}`; + controlButton.title = controlButton.ariaLabel = tr("Uncheck task: {title}", { title: task.title }); } else if (stat === "skipped") { controlButton.innerHTML = svgIcons.skipIcon; - controlButton.title = controlButton.ariaLabel = `Unskip task: ${task.title}`; + controlButton.title = controlButton.ariaLabel = tr("Unskip task: {title}", { title: task.title }); } else if (stat === "hidden") { controlButton.innerHTML = svgIcons.unhideIcon; - controlButton.title = controlButton.ariaLabel = `Unhide task: ${task.title}`; + controlButton.title = controlButton.ariaLabel = tr("Unhide task: {title}", { title: task.title }); } controlButton.addEventListener("click", hiddenTasksButtonAction(taskItem, task, stat)); @@ -1121,10 +1126,10 @@ function handleResetConfirmation(buttonElement, confirmKey, defaultText, resetAc if (key !== confirmKey && confirmState[key].isConfirming) { let btnElement, btnText; /* eslint-disable @stylistic/max-statements-per-line, @stylistic/brace-style */ - if (key === "all") { btnElement = resetButton; btnText = "Reset All Checks"; } - else if (key === "daily") { btnElement = resetDailyButton; btnText = "Reset Daily Checks"; } - else if (key === "weekly") { btnElement = resetWeeklyButton; btnText = "Reset Weekly Checks"; } - else if (key === "unhide") { btnElement = unhideTasksButton; btnText = "Unhide All Tasks"; } + if (key === "all") { btnElement = resetButton; btnText = tr("Reset All Checks"); } + else if (key === "daily") { btnElement = resetDailyButton; btnText = tr("Reset Daily Checks"); } + else if (key === "weekly") { btnElement = resetWeeklyButton; btnText = tr("Reset Weekly Checks"); } + else if (key === "unhide") { btnElement = unhideTasksButton; btnText = tr("Unhide All Tasks"); } /* eslint-enable @stylistic/max-statements-per-line, @stylistic/brace-style */ if (btnElement) { resetSpecificButtonState(btnElement, btnText, key); @@ -1139,7 +1144,7 @@ function handleResetConfirmation(buttonElement, confirmKey, defaultText, resetAc resetSpecificButtonState(buttonElement, defaultText, confirmKey); } else { confirmState[confirmKey].isConfirming = true; - buttonElement.textContent = "Are you Sure?"; + buttonElement.textContent = tr("Are you Sure?"); buttonElement.classList.add("confirming"); clearTimeout(confirmState[confirmKey].timeout); @@ -1279,9 +1284,9 @@ function saveData(showStatus = true) { if (showStatus) { showSaveStatus(); } } catch (e) { console.error("Error saving data to localStorage:", e); - let userMessage = "Could not save progress."; + let userMessage = tr("Could not save progress."); if (e.name === "QuotaExceededError" || (e.code && (e.code === 22 || e.code === 1014))) { - userMessage = "Could not save progress. Browser storage might be full."; + userMessage = tr("Could not save progress. Browser storage might be full."); } displayError(userMessage); } @@ -1301,13 +1306,14 @@ function loadData() { } else { console.warn("Invalid data format found in localStorage. Starting fresh."); } } catch (e) { console.error("Error parsing saved data:", e); - displayError("Failed to load saved progress. Data might be corrupted."); + displayError(tr("Failed to load saved progress. Data might be corrupted.")); checklistData = newChecklistData(); } } } export function loadAndInitializeApp() { /* eslint-disable-line max-lines-per-function */ + translateDocument(); initializeDOMElements(); hideError(); loadThemePreference(); @@ -1329,9 +1335,26 @@ export function loadAndInitializeApp() { /* eslint-disable-line max-lines-per-fu } else { console.error("git hash element not found!"); } if (wfVersionElement) { - wfVersionElement.textContent = `Warframe Version ${WARFRAME_VERSION}`; + wfVersionElement.textContent = tr("Warframe Version {version}", { version: WARFRAME_VERSION }); } else { console.error("Warframe version element not found!"); } + const languageSelect = document.getElementById("language-select"); + if (languageSelect) { + for (const [code, name] of Object.entries(LANGUAGE_NAMES)) { + languageSelect.add(new Option(name, code)); + } + languageSelect.value = getLanguage(); + languageSelect.addEventListener("change", (event) => { + try { + saveLanguage(event.target.value); + location.reload(); // the language is applied when the page loads + } catch (e) { + console.error("Could not save language preference:", e); + displayError(tr("Could not save language preference.")); + } + }); + } else { console.error("Language select not found!"); } + if (hideCompletedToggle) { hideCompletedToggle.addEventListener("change", (event) => { bodyElement.classList.toggle("hide-completed-tasks", event.target.checked); @@ -1343,19 +1366,19 @@ export function loadAndInitializeApp() { /* eslint-disable-line max-lines-per-fu } else { console.error("Hide Completed Toggle not found!"); } if (resetDailyButton) { - resetDailyButton.addEventListener("click", () => handleResetConfirmation(resetDailyButton, "daily", "Reset Daily Checks", resetDailyAction)); + resetDailyButton.addEventListener("click", () => handleResetConfirmation(resetDailyButton, "daily", tr("Reset Daily Checks"), resetDailyAction)); } else { console.error("Reset Daily button element not found!"); } if (resetWeeklyButton) { - resetWeeklyButton.addEventListener("click", () => handleResetConfirmation(resetWeeklyButton, "weekly", "Reset Weekly Checks", resetWeeklyAction)); + resetWeeklyButton.addEventListener("click", () => handleResetConfirmation(resetWeeklyButton, "weekly", tr("Reset Weekly Checks"), resetWeeklyAction)); } else { console.error("Reset Weekly button element not found!"); } if (resetButton) { - resetButton.addEventListener("click", () => handleResetConfirmation(resetButton, "all", "Reset All Checks", resetAllAction)); + resetButton.addEventListener("click", () => handleResetConfirmation(resetButton, "all", tr("Reset All Checks"), resetAllAction)); } else { console.error("Reset All button element not found!"); } if (unhideTasksButton) { - unhideTasksButton.addEventListener("click", () => handleResetConfirmation(unhideTasksButton, "unhide", "Unhide All Tasks", unhideAllAction)); + unhideTasksButton.addEventListener("click", () => handleResetConfirmation(unhideTasksButton, "unhide", tr("Unhide All Tasks"), unhideAllAction)); } else { console.error("Unhide Tasks button not found!"); } @@ -1430,10 +1453,10 @@ document.addEventListener("DOMContentLoaded", () => { const errDisp = document.getElementById("error-display"); const errMsg = document.getElementById("error-message"); if (errDisp && errMsg) { - errMsg.textContent = "A critical error occurred during application startup. Please check the console."; + errMsg.textContent = tr("A critical error occurred during application startup. Please check the console."); errDisp.classList.add("visible"); } else { - alert("A critical error occurred during application startup. Please check the console."); + alert(tr("A critical error occurred during application startup. Please check the console.")); } } }); diff --git a/sources/js/constants.js b/sources/js/constants.js index 2a2ca34..75edeb8 100644 --- a/sources/js/constants.js +++ b/sources/js/constants.js @@ -1,4 +1,6 @@ // --- sources/js/constants.js --- +import { t } from "./i18n.js"; + export const MILLISECONDS_PER_SECOND = 1000; export const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND; export const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE; @@ -6,7 +8,8 @@ export const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR; export const SERVER_TIMEZONE = "America/Toronto"; // used for determining Daylight Saving Time -export const BASE_OF_OPERATIONS_TOOLTIP = 'Base of Operations'; +export const BASE_OF_OPERATIONS_NAME = t("Base of Operations"); // the text task locations use (translated along with `tasks.json`) +export const BASE_OF_OPERATIONS_TOOLTIP = `${BASE_OF_OPERATIONS_NAME}`; export const TASKLIST_STAT_NAMES = { "completed": "Completed", diff --git a/sources/js/functions.js b/sources/js/functions.js index 9da275f..135f699 100644 --- a/sources/js/functions.js +++ b/sources/js/functions.js @@ -6,6 +6,7 @@ */ import * as C from "./constants.js"; +import { t, getLocaleTag } from "./i18n.js"; export function modulo(n, d) { return ((n % d) + d) % d; @@ -63,17 +64,25 @@ export function makeCycleIcon(cycleData) { } export function formatTimestamp(timestamp) { - if (!timestamp) { return "Never"; } + if (!timestamp) { return t("Never"); } try { const date = new Date(timestamp); - if (isNaN(date.getTime())) { return "Invalid Date"; } - return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); + if (isNaN(date.getTime())) { return t("Invalid Date"); } + return date.toLocaleString(getLocaleTag(), { dateStyle: "medium", timeStyle: "short" }); } catch (e) { console.error("Error formatting timestamp:", e); - return "Error"; + return t("Error"); } } +/** Formats a timestamp as a full local date and time, for tooltips. */ +export function formatDateLong(timestamp) { + const date = new Date(timestamp); + const localeTag = getLocaleTag(); + if (!localeTag) { return date.toString(); } + return date.toLocaleString(localeTag, { dateStyle: "full", timeStyle: "long" }); +} + export function getMostRecentMondayMidnightUTC() { const now = new Date(); const currentUTCDay = now.getUTCDay(); @@ -119,7 +128,7 @@ export function formatCountdown(ms) { function pad(num) { return String(num).padStart(2, "0"); } if (days > 0) { - return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + return `${days}${t("d")} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; } return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; } diff --git a/sources/js/i18n.js b/sources/js/i18n.js new file mode 100644 index 0000000..e4f0299 --- /dev/null +++ b/sources/js/i18n.js @@ -0,0 +1,146 @@ +// --- sources/js/i18n.js --- +/** Minimal localization layer. + * + * English is the source language: the English text in the code, `tasks.json`, `cycles.json`, `moreInfo.js` and + * `index.html` is used as the lookup key, so a missing translation simply falls back to English. + * Each additional language lives in `locales/.js` and is registered in `LOCALES` below. + * + * The language is chosen once per page load (saved choice > browser language > English). + * Changing it in the options menu saves the choice and reloads the page. + */ + +import ru from "./locales/ru.js"; + +export const LANGUAGE_STORAGE_KEY = "warframeChecklistLanguage"; + +/** Registered translations. English is built in and needs no entry. */ +const LOCALES = { ru }; + +/** Languages offered in the options menu, with their names written in their own language. */ +export const LANGUAGE_NAMES = { + en: "English", + ru: "Русский", +}; + +/** Returns the language code to use: the saved choice, else the first supported browser language, else "en". */ +export function detectLanguage() { + try { + const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY); + if (stored && Object.hasOwn(LANGUAGE_NAMES, stored)) { return stored; } + } catch (e) { + console.warn("Could not read language preference:", e); + } + const preferred = (typeof navigator === "undefined") ? [] : (navigator.languages ?? [navigator.language]); + for (const tag of preferred) { + const code = String(tag).toLowerCase().split("-")[0]; + if (Object.hasOwn(LANGUAGE_NAMES, code)) { return code; } + } + return "en"; +} + +let currentLanguage = detectLanguage(); + +export function getLanguage() { + return currentLanguage; +} + +/** Overrides the language for this page load (does not save it). */ +export function setLanguage(code) { + currentLanguage = Object.hasOwn(LANGUAGE_NAMES, code) ? code : "en"; +} + +/** Saves the language choice. Takes effect on the next page load. */ +export function saveLanguage(code) { + localStorage.setItem(LANGUAGE_STORAGE_KEY, code); +} + +function locale() { + return LOCALES[currentLanguage]; +} + +/** BCP 47 tag for date formatting, or undefined to use the browser default (English). */ +export function getLocaleTag() { + return locale()?.tag; +} + +function interpolate(text, vars) { + if (!vars) { return text; } + return text.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? vars[name] : match)); +} + +/** Translates a UI string. `{name}` placeholders are filled from `vars`. */ +export function t(text, vars) { + return interpolate(locale()?.ui?.[text] ?? text, vars); +} + +/** Like `t()`, but for strings whose translation depends on context (for example grammar). + * Looks up the key "context|text" first, then falls back to plain `t()`. + */ +export function tc(context, text, vars) { + return interpolate(locale()?.ui?.[`${context}|${text}`] ?? locale()?.ui?.[text] ?? text, vars); +} + +/** Translates a piece of task data (a title, location, prerequisite, etc. from `tasks.json` or `cycles.json`). */ +export function td(text) { + return locale()?.data?.[text] ?? text; +} + +const TASK_TEXT_PROPS = ["title", "text", "location", "terminal", "npc", "prereq", "info"]; + +/** Translates, in place, all the text in the task tree and the cycle schedules. Does nothing for English. */ +export function localizeData(tasks, cycles) { + if (!locale()) { return; } + + function localizeTask(task) { + for (const prop of TASK_TEXT_PROPS) { + if (task[prop]) { task[prop] = td(task[prop]); } + } + task.subtasks?.forEach(localizeTask); + } + for (const section in tasks) { + tasks[section].forEach(localizeTask); + } + + for (const id in cycles) { + for (const column of cycles[id].columns) { + column.name = td(column.name); + for (const entry of column.order) { + entry.text = td(entry.text); + } + } + } +} + +/** Returns the translated "more info" HTML by task id, or `undefined` if the current language has none. + * `helpers` are things the translated HTML needs to build itself (icons, tooltips). + */ +export function localizeMoreInfo(helpers) { + return locale()?.moreInfo?.(helpers); +} + +/** Translates the static text and the `title`/`aria-label` attributes of the markup in `index.html`. */ +export function translateDocument(root = document) { + if (!locale()) { return; } + + document.documentElement.lang = currentLanguage; + document.title = t(document.title); + + const walker = document.createTreeWalker(root.body ?? root, NodeFilter.SHOW_TEXT); + const textNodes = []; + while (walker.nextNode()) { textNodes.push(walker.currentNode); } + for (const node of textNodes) { + const original = node.nodeValue; + const trimmed = original.trim(); + if (!trimmed || node.parentElement.closest("script, style")) { continue; } + const translated = t(trimmed); + if (translated !== trimmed) { + node.nodeValue = original.replace(trimmed, () => translated); + } + } + + for (const attribute of ["title", "aria-label"]) { + root.querySelectorAll(`[${attribute}]`).forEach((el) => { + el.setAttribute(attribute, t(el.getAttribute(attribute))); + }); + } +} diff --git a/sources/js/locales/ru.js b/sources/js/locales/ru.js new file mode 100644 index 0000000..837b6e6 --- /dev/null +++ b/sources/js/locales/ru.js @@ -0,0 +1,453 @@ +// --- sources/js/locales/ru.js --- +/** Russian translation. See `../i18n.js` for how it is used. + * + * Game terms follow the official Russian game localization (the in-game text, which the Russian Warframe wiki also uses). + * Strings in `ui` and `data` are keyed by the English text; a missing key falls back to English. + */ + +export default { + tag: "ru-RU", + + // UI text used in `index.html` and `app.js`. {placeholders} must be kept. "context|text" keys are context-specific variants. + ui: { + "Warframe Task Checklist": "Чек-лист задач Warframe", + "Track your daily, weekly, and other Warframe tasks.": "Отслеживайте ежедневные, еженедельные и прочие задачи Warframe.", + "Toggle theme": "Переключить тему", + "Open menu": "Открыть меню", + "Close error message": "Закрыть сообщение об ошибке", + "Close menu": "Закрыть меню", + "Close schedule": "Закрыть", + "Copy": "Копировать", + "Failed": "Не удалось", + "Copied!": "Скопировано!", + "Daily Tasks": "Ежедневные задачи", + "Weekly Tasks": "Еженедельные задачи", + "Other Tasks": "Прочие задачи", + "(Resets ...)": "(Сброс ...)", + "Hide Section": "Скрыть раздел", + "Saved!": "Сохранено!", + "Loading...": "Загрузка...", + "Your progress is saved locally.": "Ваш прогресс сохраняется локально.", + "*Remember, you don't have to do everything! Prioritize tasks based on your current goals and progress.*": "*Помните: делать всё необязательно! Расставляйте приоритеты, исходя из ваших текущих целей и прогресса.*", + "License (GPLv3)": "Лицензия (GPLv3)", + "Warframe Wiki": "Вики Warframe", + "App Version": "Версия приложения", + "Warframe Version X": "Версия Warframe X", + "This is an unofficial fan-made tool. Warframe and all related assets are the intellectual property of Digital Extremes Ltd. This project is not affiliated with, endorsed by, or sponsored by Digital Extremes Ltd.": "Это неофициальный фанатский инструмент. Warframe и все связанные с ним материалы являются интеллектуальной собственностью Digital Extremes Ltd. Этот проект не связан с Digital Extremes Ltd, не одобрен и не спонсируется ею.", + "Options": "Настройки", + "Hide completed tasks": "Скрывать выполненные задачи", + "Language": "Язык", + "Reset Daily Checks": "Сбросить ежедневные отметки", + "Reset Weekly Checks": "Сбросить еженедельные отметки", + "Reset All Checks": "Сбросить все отметки", + "Unhide All Tasks": "Показать все задачи", + "Are you Sure?": "Вы уверены?", + "Schedule for": "Расписание:", + "Base of Operations": "База операций", + "Orbiter, Drifter's Camp, or Backroom": "Орбитер, Лагерь Скитальца или Секретная комната", + "Could not save theme preference.": "Не удалось сохранить выбранную тему.", + "Could not save language preference.": "Не удалось сохранить выбранный язык.", + "Could not save progress.": "Не удалось сохранить прогресс.", + "Could not save progress. Browser storage might be full.": "Не удалось сохранить прогресс. Возможно, хранилище браузера заполнено.", + "Failed to load saved progress. Data might be corrupted.": "Не удалось загрузить сохранённый прогресс. Возможно, данные повреждены.", + "A critical error occurred during application startup. Please check the console.": "Критическая ошибка при запуске приложения. Подробности смотрите в консоли.", + "Last saved: {time}": "Последнее сохранение: {time}", + "Never": "Никогда", + "Invalid Date": "Неверная дата", + "Error": "Ошибка", + "d": "д", + "(Resets in {time})": "(Сброс через {time})", + "(Available for {time})": "(Доступно ещё {time})", + "(Available in {time})": "(Будет доступно через {time})", + "(Resets 00:00 UTC)": "(Сброс в 00:00 UTC)", + "(Resets Mon 00:00 UTC)": "(Сброс в пн в 00:00 UTC)", + "(Loading...)": "(Загрузка...)", + "{title} Leaving Soon!": "{title}: скоро уйдёт!", + "Approximately {minutes} minutes remaining.": "Осталось примерно {minutes} мин.", + "{title} has reset!": "{title}: ассортимент обновился!", + "Vendor stock may have updated.": "Ассортимент торговца мог измениться.", + "This browser does not support desktop notifications.": "Этот браузер не поддерживает уведомления на рабочем столе.", + "Notification permission was denied. You can enable it in your browser settings.": "Разрешение на уведомления не получено. Вы можете включить его в настройках браузера.", + "Notification permission has been denied. Please enable it in your browser settings if you wish to receive notifications.": "Разрешение на уведомления отклонено. Если хотите получать уведомления, включите их в настройках браузера.", + "Toggle notifications for {title}": "Переключить уведомления: {title}", + "this cycle": "в этом цикле", + "today": "сегодня", + "this week": "на этой неделе", + "Skip task {when}: {title}": "Пропустить задачу ({when}): {title}", + "Hide task until it becomes available: {title}": "Скрыть задачу, пока она не станет доступна: {title}", + "Hide task: {title}": "Скрыть задачу: {title}", + "{count} incomplete subtasks of {title}": "Невыполненных подзадач ({title}): {count}", + "Uncheck task: {title}": "Снять отметку с задачи: {title}", + "Unskip task: {title}": "Вернуть пропущенную задачу: {title}", + "Unhide task: {title}": "Показать скрытую задачу: {title}", + "Date": "Дата", + "Now": "Сейчас", + "(Cycle Repeats)": "(Цикл повторяется)", + "This Week": "На этой неделе", + "Today": "Сегодня", + "Current Cycle": "Текущий цикл", + "Next Cycle": "Следующий цикл", + "Show Schedule": "Показать расписание", + "More Info": "Подробнее", + "Location": "Локация", + "NPC": "Персонаж", + "Terminal": "Терминал", + "Requirements": "Требования", + "Info": "Информация", + "Completed": "Выполнено", + "Skipped": "Пропущено", + "Hidden": "Скрыто", + "list|Completed": "Выполненные", + "list|Skipped": "Пропущенные", + "list|Hidden": "Скрытые", + "list|{stat} subtasks of ": "{stat} подзадачи: ", + "list|Daily": "ежедневные", + "list|Weekly": "еженедельные", + "list|Other": "прочие", + "list| Tasks": " задачи", + "Warframe Version {version}": "Версия Warframe {version}", + }, + + // text from `tasks.json` and `cycles.json` + data: { + "Log in": "Вход в игру", + "Collect the daily login reward.": "Заберите ежедневную награду за вход.", + "Crafting (Forma)": "Создание (Форма)", + "Start building a new Forma (and collect finished ones).": "Начните создание новой Формы (и заберите готовые).", + "Base of Operations": "База операций", + "Foundry": "Кузница", + "Crafting (Other)": "Создание (прочее)", + "Craft other daily resources/items using reusable blueprints (check Foundry/companion app).": "Создавайте другие ежедневные ресурсы и предметы по многоразовым чертежам (проверьте Кузницу или приложение-компаньон).", + "Daily First Win Bonus": "Бонус за первую победу дня", + "Get double base credit reward on your first mission.": "Получите двойную базовую награду кредитами за первую миссию.", + "Navigation": "Навигация", + "Faction Syndicates": "Синдикаты фракций", + "Gain daily standing cap with your pledged Syndicate(s).": "Наберите дневной максимум репутации в синдикатах, которым вы присягнули.", + "Mastery Rank 3": "Ранг мастерства 3", + "Gain affinity in any mission to increase faction standing": "Получайте синтез в любой миссии, чтобы повысить репутацию во фракции", + "If maxed on standing, spend it (Relic packs, Vosfor packs, etc.).": "Если репутация на максимуме, потратьте её (наборы реликвий, наборы Восфора и т. д.).", + "Base of Operations/Any Relay": "База операций/Любое реле", + "Syndicates": "Синдикаты", + "World Syndicates": "Мировые синдикаты", + "Gain standing.": "Наберите репутацию.", + "Cephalon Simaris": "Цефалон Симэрис", + "Any Relay": "Любое реле", + "Ostron": "Острон", + "Cetus, Earth": "Цетус, Земля", + "Saya's Vigil": "Дозор Сайи", + "The Quills": "Перья", + "The War Within": "Война внутри", + "Solaris United": "Объединение Солярис", + "Fortuna, Venus": "Фортуна, Венера", + "Vox Solaris (Quest)": "Глас Солярис (квест)", + "Vox Solaris": "Глас Солярис", + "Ventkids": "Дети Труб", + "Entrati": "Энтрати", + "Necralisk, Deimos": "Некралиск, Деймос", + "Heart of Deimos": "Сердце Деймоса", + "Necraloid": "Некралоид", + "The Holdfasts": "Неукротимые", + "Chrysalith, Zariman": "Хризалит, Зариман", + "Angels of the Zariman": "Ангелы Заримана", + "Cavia": "Кавия", + "Sanctum Anatomica, Deimos": "Санктум Анатомика, Деймос", + "Whispers in the Walls": "Шёпот в стенах", + "The Hex": "Гекс", + "Höllvania Central Mall": "Торговый центр Хёльвании", + "The Hex (Quest)": "Гекс (квест)", + "Nightwave": "Ночная Волна", + "Complete daily Nightwave acts.": "Выполняйте ежедневные акты Ночной Волны.", + "Sortie": "Вылазка", + "Complete the 3 daily Sortie missions.": "Пройдите 3 ежедневные миссии Вылазки.", + "Focus": "Фокус", + "Max out daily Focus gain (e.g., via Sanctuary Onslaught).": "Наберите максимум Фокуса за день (например, через Резню в Святилище).", + "The Second Dream": "Второй сон", + "Steel Path Incursions": "Вторжения Стального пути", + "Complete daily Steel Path missions for Steel Essence.": "Проходите ежедневные миссии Стального пути ради Эссенции стали.", + "Steel Path unlocked": "Стальной путь открыт", + "complete daily chats with protoframes.": "Проходите ежедневные диалоги с протофреймами.", + "POM-2 PC": "Компьютер ПОМ-2", + "Broadsword (Arthur)": "Broadsword (Артур)", + "Salem (Eleanor)": "Salem (Элеонора)", + "Belladona ~{@ (Lettie)": "Belladona ~{@ (Лэтти)", + "H16h V0l7463 (Amir)": "H16h V0l7463 (Амир)", + "xX GLIMMER Xx (Aoi)": "xX GLIMMER Xx (Аои)", + "Soldja1Shot1kil (Quincy)": "Soldja1Shot1kil (Куинси)", + "The Roundtable": "Круглый стол", + "The Hex (Quest) Finale": "Финал Гекса (квест)", + "Liminus_Star (Flare)": "Liminus_Star (Флэр)", + "Rank 4 The Hex": "Гекс, ранг 4", + "MomToxicated & PapaPolar (Minerva & Velimir)": "MomToxicated & PapaPolar (Минерва и Велимир)", + "Rank 5 The Hex": "Гекс, ранг 5", + "KOLTrial_5115 (Kaya)": "KOLTrial_5115 (Кайя)", + "The Devil's Triad": "Дьявольская триада", + "The Old Peace": "Стародавний мир", + "Marie": "Мари", + "Roathe": "Роут", + "Lyon": "Лион", + "\"Liked\" by Marie": "Уровень «Нравится» у Мари", + "Fables & Frontiers": "Предания и Просторы", + "\"Close\" with Amir": "Уровень «Близкий» у Амира", + "Vendors": "Торговцы", + "Acrithis": "Акритис", + "Check daily Arcane and Captura offering.": "Проверьте ежедневное предложение мистификаторов и сцен Каптуры.", + "Duviri/Dormizone": "Дувири/Дормизон", + "Drifter Intrinsics: Opportunity Rank 9 (for Arcanes)": "Врождённые навыки Скитальца: Возможность, ранг 9 (для мистификаторов)", + "Ticker": "Тикер", + "Check available railjack crew to hire.": "Проверьте, каких членов экипажа Рэйлджека можно нанять.", + "Rising Tide & Command Intrinsics 1": "Восходящий прилив и Командование 1", + "Purchase Tektolyst mods, arcanes, and Perita/Descendia resources.": "Покупайте моды Тектолиста, мистификаторы и ресурсы Периты/Спуска.", + "La Cathédrale (Sanctum Anatomica, Deimos)": "La Cathédrale (Санктум Анатомика, Деймос)", + "Complete relevant weekly Nightwave acts.": "Выполняйте нужные еженедельные акты Ночной Волны.", + "Nightwave (Spend)": "Ночная Волна (траты)", + "Spend Nightwave credits if needed (Aura mods, Catalysts/Reactors, etc.).": "При необходимости потратьте кредиты Ночной Волны (моды Ауры, Катализаторы/Реакторы и т. д.).", + "Ayatan Treasure Hunt": "Охота за сокровищами Аятан", + "Complete Maroo's weekly mission for an Ayatan Sculpture.": "Выполните еженедельную миссию Мэру ради Скульптуры Аятан.", + "Maroo's Bazaar, Mars": "Базар Мэру, Марс", + "Maroo": "Мэру", + "Help Clem": "Помощь Клему", + "Help Clem with his weekly survival, or he will die.": "Помогите Клему с его еженедельным выживанием, иначе он погибнет.", + "Darvo": "Дарво", + "A Man of Few Words": "Немногословный человек", + "Break Narmer": "Сокрушить Нармер", + "Complete Kahl's weekly mission for Stock.": "Выполните еженедельную миссию Кахла ради Запаса.", + "Drifter's Camp, Earth": "Лагерь Скитальца, Земля", + "Kahl": "Кахл", + "Veilbreaker": "Срывающий вуаль", + "Archon Hunt": "Охота на Архонта", + "Complete the weekly Archon Hunt for a guaranteed Archon Shard.": "Пройдите еженедельную Охоту на Архонта и гарантированно получите Осколок архонта.", + "The New War": "Новая Война", + "Duviri Circuit (Normal)": "Цепь Дувири (обычная)", + "Check weekly Warframe options & run Circuit if desired.": "Проверьте еженедельные варианты варфреймов и при желании пройдите Цепь.", + "Base of Operations/Dormizone": "База операций/Дормизон", + "The Duviri Paradox": "Парадокс Дувири", + "Duviri Circuit (Steel Path)": "Цепь Дувири (Стальной путь)", + "Check weekly Incarnon Adapters & run Circuit if desired.": "Проверьте еженедельные Адаптеры Инкарнона и при желании пройдите Цепь.", + "Steel Path unlocked & The Duviri Paradox": "Стальной путь открыт и Парадокс Дувири", + "Search Pulses": "Импульсы поиска", + "Use 5 weekly search pulses on Netracells and Archimedeas.": "Потратьте 5 еженедельных импульсов поиска на Ячейки Нетры и Архимедии.", + "Netracells": "Ячейки Нетры", + "Complete up to 5 weekly Netracell missions for Archon Shard chances.": "Пройдите до 5 еженедельных миссий Ячеек Нетры ради шанса получить Осколок архонта.", + "Tagfer": "Тагфер", + "Costs 1 Search Pulse per successful mission": "Стоит 1 импульс поиска за каждую успешную миссию", + "Elite Deep Archimedea": "Элитная Глубинная Архимедия", + "Attempt weekly Elite Deep Archimedea for high Archon Shard chances (very endgame).": "Попытайтесь пройти еженедельную Элитную Глубинную Архимедию ради высокого шанса получить Осколок архонта (самый конец игры).", + "Rank 5 Cavia": "Кавия, ранг 5", + "Costs 2 Search Pulses to unlock for the week": "Открытие на неделю стоит 2 импульса поиска", + "Elite Temporal Archimedea": "Элитная Временная Архимедия", + "Attempt weekly Elite Temporal Archimedea for high Archon Shard chances (very endgame).": "Попытайтесь пройти еженедельную Элитную Временную Архимедию ради высокого шанса получить Осколок архонта (самый конец игры).", + "Kaya Velasco": "Кайя Веласко", + "Cryobells": "Криоколокола", + "Harvest 3 Cryobells from Yuvan Peak.": "Соберите 3 криоколокола на Пике Юван.", + "Yuvan Peak, Earth": "Пик Юван, Земля", + "Angels of the Zariman & Whispers in the Walls": "Ангелы Заримана и Шёпот в стенах", + "Up to 6 Cryobells can be owned at a time": "Одновременно можно иметь не более 6 криоколоколов", + "The Icebind": "Ледяные Узы", + "Spend Cryobells to run six-player Icebind missions.": "Тратьте криоколокола, чтобы проходить миссии Ледяных Уз на шестерых игроков.", + "Cephalon Melica": "Цефалон Мелика", + "Angels of the Zariman, Whispers in the Walls, & unlocked EDA or ETA": "Ангелы Заримана, Шёпот в стенах и открытая ЭГА или ЭВА", + "Costs 1 Cryobell per player per successful mission": "Стоит 1 криоколокол на каждого игрока за каждую успешную миссию", + "1999 Calendar": "Календарь 1999", + "Complete weekly Calendar tasks.": "Выполняйте еженедельные задачи Календаря.", + "Helminth": "Гельминт", + "Use weekly Invigorations.": "Используйте еженедельные Воодушевления.", + "Rank 5 Entrati": "Энтрати, ранг 5", + "The Descendia (Normal)": "Спуск (обычный)", + "Weekly Tower gamemode for various resources.": "Еженедельный режим «Башня» ради разных ресурсов.", + "Base of Operations or Sanctum Anatomica, Deimos": "База операций или Санктум Анатомика, Деймос", + "Dark Refractory": "Тёмный Рефракторий", + "The Descendia (Steel Path)": "Спуск (Стальной путь)", + "Oops! All Eximus!": "Упс! Одни эксимусы!", + "Exterminate Surge Eximus units for a Surge Eximus Specter Blueprint.": "Уничтожайте Заряженных Эксимусов ради чертежа Спектра Заряженного Эксимуса.", + "Amir (Höllvandeim)": "Амир (Хёльвандейм)", + "Complete all 6 days of the Fables & Frontiers KIM story": "Пройдите все 6 дней истории KIM «Предания и Просторы»", + "Clan Weekly Initiatives": "Еженедельные инициативы клана", + "Earn rewards by playing with clan members.": "Получайте награды за игру с соклановцами.", + "Any Mission*": "Любая миссия*", + "Menu 🞂 Communication 🞂 Clan": "Меню 🞂 Общение 🞂 Клан", + "Membership in a Clan": "Членство в клане", + "Palladino": "Палладино", + "Trade Riven Slivers.": "Обменивайте Осколки Разлома.", + "Iron Wake, Earth": "Железная Цитадель, Земля", + "The Chains of Harrow": "Цепи Харроу", + "Archimedean Yonta": "Архимедиан Йонта", + "Buy weekly Kuva with Voidplumes.": "Покупайте еженедельную Куву за Перья Бездны.", + "Yonta": "Йонта", + "Check wares and spend Pathos Clamps if desired (Catalysts/Reactors recommended if needed). Buy Kuva with Scuttler Husks.": "Проверьте ассортимент и при желании потратьте Струбцины пафоса (если нужно, рекомендуются Катализаторы/Реакторы). Покупайте Куву за Панцири Скаттлера.", + "Teshin (Steel Path)": "Тешин (Стальной путь)", + "Check Teshin's Steel Essence shop (especially for Umbra Forma rotation every 8 weeks).": "Проверьте магазин Эссенции стали у Тешина (особенно ротацию Форма: Умбра раз в 8 недель).", + "Teshin": "Тешин", + "Bird 3": "Птичка-3", + "Buy the weekly Archon Shard for 30k Cavia Standing.": "Купите еженедельный Осколок архонта за 30 тыс. репутации Кавии.", + "Kaya": "Кайя", + "Trade Pix Chips for Arcanes.": "Обменивайте Чипсели на мистификаторы.", + "Nightcap": "Мракомор", + "Trade Fergolyte for Kuva and Ayatan Sculpture.": "Обменивайте Ферголит на Куву и Скульптуру Аятан.", + "Aspirant Zorba": "Аспирант Зорба", + "Trade Atramentum for Kuva.": "Обменивайте Атраментум на Куву.", + "Hunhow": "Хунхау", + "Trade Emerald & Crimson Talents for Kuva.": "Обменивайте Изумрудные и Багровые Таланты на Куву.", + "Pontis Tower, Uranus": "Башня Понтис, Уран", + "Jade Shadows: Constellations": "Нефритовые Тени: Созвездия", + "Trade Entropic Kuva for Kuva.": "Обменивайте Энтропийная Кува на Куву.", + "Baro Ki'Teer": "Баро Ки'Тиир", + "Check Baro Ki'Teer's inventory and purchase desired items with Ducats (trade Prime parts for Ducats).": "Проверьте ассортимент Баро Ки'Тиира и купите нужное за Дукаты (обменивайте прайм-части на Дукаты).", + "Relay with Symbol": "Реле с символом", + "Mend the Family": "Исцелить семью", + "Purchase Family Tokens from Grandmother.": "Покупайте у Бабушки Семейные знаки.", + "Trade for Voidplumes": "Обмен на Перья Бездны", + "Trade for Voca": "Обмен на Вока", + "Loid": "Лойд", + "Tenet Weapons": "Оружие Догмат", + "Check Ergo Glast's shop for good valence bonuses.": "Проверьте магазин Эрго Гласта на предмет хороших бонусов валентности.", + "Ergo Glast": "Эрго Гласт", + "The Archwing + Mastery Rank 14": "Арчвинг + ранг мастерства 14", + "Coda Weapons": "Оружие Кода", + "Check Eleanor's shop for good valence bonuses.": "Проверьте магазин Элеоноры на предмет хороших бонусов валентности.", + "Eleanor Nightingale": "Элеонора Найтингейл", + "Item": "Предмет", + "Ayatan Sah Sculpture": "Скульптура Аятан: Сак", + "Ayatan Ayr Sculpture": "Скульптура Аятан: Эр", + "Ayatan Orta Sculpture": "Скульптура Аятан: Орта", + "Ayatan Vaya Sculpture": "Скульптура Аятан: Вайя", + "Ayatan Piv Sculpture": "Скульптура Аятан: Пив", + "Ayatan Valana Sculpture": "Скульптура Аятан: Валана", + "Tileset": "Тайлсет", + "Orokin Derelict (Deimos)": "Руины Орокин (Деймос)", + "Orokin Tower (Void)": "Башня Орокин (Бездна)", + "Mission": "Миссия", + "Sneaky Sabotage": "Скрытный саботаж", + "Junk Run": "Сбор мусора", + "Prison Break": "Побег из тюрьмы", + "Spaceport (Orb Vallis, Venus)": "Космопорт (Долина Сфер, Венера)", + "Grineer Forest (Earth)": "Лес Гринир (Земля)", + "Murex": "Мюрекс", + "Azure Archon Shard": "Лазурный осколок архонта", + "Crimson Archon Shard": "Багровый осколок архонта", + "Amber Archon Shard": "Янтарный осколок архонта", + "Boss": "Босс", + "🦉 Archon Boreal": "🦉 Архонт Бореаль", + "🐺 Archon Amar": "🐺 Архонт Амар", + "🐍 Archon Nira": "🐍 Архонт Нира", + "Grineer Settlement (Mars)": "Поселение Гринир (Марс)", + "Corpus Gas City (Jupiter)": "Газовый город Корпуса (Юпитер)", + "Items": "Предметы", + "Blueprints for Excalibur, Trinity, or Ember": "Чертежи: Экскалибур, Тринити или Эмбер", + "Blueprints for Loki, Mag, or Rhino": "Чертежи: Локи, Мэг или Рино", + "Blueprints for Ash, Frost, or Nyx": "Чертежи: Эш, Фрост или Никс", + "Blueprints for Saryn, Vauban, or Nova": "Чертежи: Сарина, Вобан или Нова", + "Blueprints for Nekros, Valkyr, or Oberon": "Чертежи: Некрос, Валькирия или Оберон", + "Blueprints for Hydroid, Mirage, or Limbo": "Чертежи: Гидроид, Мираж или Лимбо", + "Blueprints for Mesa, Chroma, or Atlas": "Чертежи: Миса, Хрома или Атлас", + "Blueprints for Ivara, Inaros, or Titania": "Чертежи: Ивара, Инарос или Титания", + "Blueprints for Nidus, Octavia, or Harrow": "Чертежи: Нидус, Октавия или Харроу", + "Blueprints for Gara, Khora, or Revenant": "Чертежи: Гара, Кора или Ревенант", + "Blueprints for Garuda, Baruuk, or Hildryn": "Чертежи: Гаруда, Баруук или Хильдрин", + "Incarnon Adapters for Braton, Kunai, Lato, Paris, and Skana": "Адаптеры Инкарнона: Брэйтон, Кунаи, Лато, Парис и Скана", + "Incarnon Adapters for Angstrum, Anku, Boar, Gammacor, and Gorgon": "Адаптеры Инкарнона: Ангстрем, Анку, Вепрь, Гаммакор и Горгона", + "Incarnon Adapters for Bo, Furax, Furis, Latron, and Strun": "Адаптеры Инкарнона: Бо, Фуракс, Фурис, Латрон и Стран", + "Incarnon Adapters for Boltor, Bronco, Ceramic Dagger, Lex, and Magistar": "Адаптеры Инкарнона: Болтор, Бронко, Керамический кинжал, Лекс и Магистр", + "Incarnon Adapters for Atomos, Dual Ichor, Dual Toxocyst, Miter, and Torid": "Адаптеры Инкарнона: Атомос, Парные Ихоры, Парные Токсоцисты, Митра и Торид", + "Incarnon Adapters for Ack & Brunt, Burston, Nami Solo, Soma, and Vasto": "Адаптеры Инкарнона: Ак и Брант, Бёрстон, Нами Соло, Сома и Васто", + "Incarnon Adapters for Despair, Dread, Hate, Sibear, and Zylok": "Адаптеры Инкарнона: Отчаяние, Страх, Ненависть, Сибирь и Зайлок", + "Incarnon Adapters for Cestra, Dera, Okina, Sicarus, and Sybaris": "Адаптеры Инкарнона: Цестра, Дера, Окина, Сикарус и Сибарис", + "Incarnon Adapters for Ballistica, Destreza, Obex, Stug, and Vectis": "Адаптеры Инкарнона: Баллистика, Дестреза, Обекс, Стаг и Вектис", + "Season": "Сезон", + "Winter": "Зима", + "Spring": "Весна", + "Summer": "Лето", + "Autumn": "Осень", + "Increased Spawns": "Усиленное появление", + "❄️ Arctic Eximus": "❄️ Арктический Эксимус", + "🟢 Jade Light Eximus": "🟢 Эксимус Нефритового света", + "🔥 Arson Eximus": "🔥 Эксимус-поджигатель", + "🧲 Energy Leech Eximus": "🧲 Эксимус-энерговампир", + "Umbra Forma Blueprint": "Чертёж Форма: Умбра", + "50,000 Kuva": "50 000 Кувы", + "Kitgun Riven Mod": "Мод Разлома для Китганов", + "3x Built Forma": "3 готовые Формы", + "Zaw Riven Mod": "Мод Разлома для Зо", + "30,000 Endo": "30 000 Эндо", + "Rifle Riven Mod": "Мод Разлома для Винтовок", + "Shotgun Riven Mod": "Мод Разлома для Дробовиков", + "Price": "Цена", + "Location": "Локация", + "Strata Relay, Earth": "Реле Strata, Земля", + "Larunda Relay, Mercury": "Реле Larunda, Меркурий", + "Kronia Relay, Saturn": "Реле Kronia, Сатурн", + "Orcus Relay, Pluto (MR 8+)": "Реле Orcus, Плутон (ранг мастерства 8+)", + "Hema, Sporothrix, Catabolyst, Pox, Dual Torxica, Mire, and Motovore": "Гема, Споротрикс, Катаболист, Покс, Парные Торксики, Майр и Мотовор", + "Bassocyst, Bubonico, Synapse, Tysis, Caustacyst, Hirudo, and Pathocyst": "Бассоцист, Бубонико, Синапс, Тайсис, Костацист, Хирудо и Патоцист", + }, + + /** the "more info" dialogs (replaces `moreInfo.js`). `helpers` come from that file. */ + moreInfo({ factionIcon, baseOfOperations }) { /* eslint-disable-line max-lines-per-function */ + const factions = `

Синдикаты фракций: + ${factionIcon("FactionSigilRebels.png")}Стальной Меридиан, + ${factionIcon("FactionSigilJudge.png")}Арбитры Гексиса, + ${factionIcon("FactionSigilOracle.png")}Цефалон Суда, + ${factionIcon("FactionSigilBusiness.png")}Последовательность Перрина, + ${factionIcon("FactionSigilAssassins.png")}Красная Вуаль + и ${factionIcon("FactionSigilChurch.png")}Новая Лока.

+

Присягните на верность синдикату фракции у консоли Синдикатов в вашей ${baseOfOperations}.

`; + + return { + daily_first_win_bonus: `

Ваша первая завершённая миссия после ежедневного сброса приносит вдвое больше базовой награды кредитами. Это относится только к награде в конце миссии и не распространяется на кредиты, подобранные во время миссии.

+

Бонус также не действует в следующих типах миссий и локациях:

+
    +
  • Индекс
  • +
  • Открытые миры (включая Извлекателя Прибыли)
  • +
  • Зариман
  • +
  • Хёльвания (включая сейфы Техрота)
  • +
+

Прохождение любой из этих миссий потратит бонус без дополнительных кредитов.

+

Бонус суммируется с Умножителями кредитов, Благословениями кредитов и событиями с двойными кредитами. Переход на реле или в другую зону без миссий бонус не тратит.

+

Вот несколько хороших миссий, где стоит его использовать:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
МестоТипБаз. кредитыУр. врагов
Tikal, ЗемляРаскопки (Тёмный сектор)13 5006 - 16
Gabii, ЦерераВыживание (Тёмный сектор)22 40015 - 25
Кластер Бендар, Проксима ЗемлиСтычка Рэйлджека48 80029 - 36
Облако Сабмира, Проксима ВуалиШпионаж Рэйлджека156 60056 - 60
+

Подробнее — на вики: Бонус за первую победу дня, Тёмные сектора и Миссии.

+

(Сейчас есть визуальный баг: бонусные кредиты не отображаются на экране завершения миссии. Но на ваш аккаунт они всё равно зачисляются.)

`, + + daily_syndicate_gain: factions, + daily_syndicate_spend: factions, + + weekly_clan_initiative: `

Играйте в отряде с другими участниками вашего клана, чтобы получать бонусные ресурсы для Хранилища клана. Бонусные ресурсы также идут в ваш личный трек наград на неделю. Прогресс зависит от количества ресурсов, собранных в миссиях с соклановцами.

+

Каждую неделю случайная планета даёт вдвое больше бонусных ресурсов Хранилища (и вдвое больше прогресса трека наград) за миссии, сыгранные на ней.

+

Текущую усиленную планету и прогресс наград можно посмотреть в меню Клана.

+

Примечание: чтобы играть в отрядах с участниками клана или альянса, можно использовать подбор игроков «Только клан», но игра с участниками альянса не даёт бонусных ресурсов Хранилища и прогресса наград.

+

* Миссии Спуска не засчитываются в Еженедельные инициативы клана.

`, + }; + }, +}; diff --git a/sources/js/moreInfo.js b/sources/js/moreInfo.js index 24846fb..1febde6 100644 --- a/sources/js/moreInfo.js +++ b/sources/js/moreInfo.js @@ -4,6 +4,7 @@ import * as C from "./constants.js"; import { factionIcon } from "./functions.js"; +import { localizeMoreInfo } from "./i18n.js"; const factions = `

The Faction Syndicates are ${factionIcon("FactionSigilRebels.png")}Steel Meridian, @@ -14,7 +15,7 @@ const factions = `

The Faction Syndicates are and ${factionIcon("FactionSigilChurch.png")}New Loka.

Pledge your loyalty to a faction syndicate at the Syndicates console in your ${C.BASE_OF_OPERATIONS_TOOLTIP}.

`; -export default { +const english = { daily_first_win_bonus: `

Your first completed mission after the daily reset gives double base credit rewards. This only applies to the end-of-mission bonus, and does not apply to credits picked up in the mission.

It also does not apply to the following mission types and locations:

    @@ -72,3 +73,6 @@ export default {

    Note: You can use the "Clan Only" matchmaking option to join squads with your Clan or Alliance members, but playing with Alliance members does not give bonus Vault resources or reward progress.

    * Descendia missions do not contribute to Clan Weekly Initiatives.

    `, }; + +// the translation (if there is one for the current language) replaces the whole object +export default localizeMoreInfo({ factionIcon, baseOfOperations: C.BASE_OF_OPERATIONS_TOOLTIP }) ?? english; diff --git a/sources/tests/i18n.test.js b/sources/tests/i18n.test.js new file mode 100644 index 0000000..b5333ae --- /dev/null +++ b/sources/tests/i18n.test.js @@ -0,0 +1,91 @@ +/// + +import { afterEach, describe, expect, test } from "vitest"; +import { getLanguage, localizeData, setLanguage, t, tc, td } from "../js/i18n.js"; +import ru from "../js/locales/ru.js"; + +afterEach(() => { + setLanguage("en"); +}); + +describe("language selection", () => { + test("defaults to English in the test environment", () => { + expect(getLanguage()).toBe("en"); + }); + + test("unknown languages fall back to English", () => { + setLanguage("xx"); + expect(getLanguage()).toBe("en"); + }); +}); + +describe("t()", () => { + test("English text is returned as is, with placeholders filled in", () => { + expect(t("Copy")).toBe("Copy"); + expect(t("Hide task: {title}", { title: "Sortie" })).toBe("Hide task: Sortie"); + }); + + test("missing translations fall back to English", () => { + setLanguage("ru"); + expect(t("This text has no translation")).toBe("This text has no translation"); + expect(t("{a} and {b}", { a: 1 })).toBe("1 and {b}"); + }); + + test("translates and fills in placeholders", () => { + setLanguage("ru"); + expect(t("Hide task: {title}", { title: "Вылазка" })).toBe("Скрыть задачу: Вылазка"); + }); + + test("context-specific text falls back to the plain translation, then to English", () => { + expect(tc("list", "Completed")).toBe("Completed"); + setLanguage("ru"); + expect(tc("list", "Completed")).toBe("Выполненные"); + expect(t("Completed")).toBe("Выполнено"); + expect(tc("list", "Copy")).toBe("Копировать"); + }); +}); + +describe("localizeData()", () => { + function sample() { + return [ + { daily: [{ id: "daily_x", title: "Sortie", prereq: "The War Within", subtasks: [{ id: "daily_y", title: "Ostron" }] }] }, + { daily_x: { columns: [{ name: "Item", order: [{ text: "Winter" }] }] } }, + ]; + } + + test("does nothing for English", () => { + const [tasks, cycles] = sample(); + localizeData(tasks, cycles); + expect(tasks).toEqual(sample()[0]); + expect(cycles).toEqual(sample()[1]); + }); + + test("translates tasks, subtasks, and cycle schedules", () => { + setLanguage("ru"); + const [tasks, cycles] = sample(); + localizeData(tasks, cycles); + expect(tasks.daily[0].title).toBe("Вылазка"); + expect(tasks.daily[0].prereq).toBe("Война внутри"); + expect(tasks.daily[0].subtasks[0].title).toBe("Острон"); + expect(cycles.daily_x.columns[0].name).toBe("Предмет"); + expect(cycles.daily_x.columns[0].order[0].text).toBe("Зима"); + }); + + test("untranslated data is left alone", () => { + setLanguage("ru"); + const tasks = { daily: [{ id: "daily_x", title: "Something new" }] }; + localizeData(tasks, {}); + expect(tasks.daily[0].title).toBe("Something new"); + expect(td("Something new")).toBe("Something new"); + }); +}); + +describe("translations", () => { + function placeholders(text) { + return (text.match(/\{\w+\}/g) ?? []).sort(); + } + + test.for(Object.entries(ru.ui))("ru.ui[%j] keeps the same placeholders", ([key, value]) => { + expect(placeholders(value)).toEqual(placeholders(key)); + }); +});