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 0000000..dd14bcf Binary files /dev/null and b/sources/fonts/inter-cyrillic-500.woff2 differ diff --git a/sources/fonts/inter-cyrillic-600.woff2 b/sources/fonts/inter-cyrillic-600.woff2 new file mode 100644 index 0000000..e250ef0 Binary files /dev/null and b/sources/fonts/inter-cyrillic-600.woff2 differ diff --git a/sources/fonts/inter-cyrillic-700.woff2 b/sources/fonts/inter-cyrillic-700.woff2 new file mode 100644 index 0000000..781e4b4 Binary files /dev/null and b/sources/fonts/inter-cyrillic-700.woff2 differ diff --git a/sources/fonts/inter-cyrillic-regular.woff2 b/sources/fonts/inter-cyrillic-regular.woff2 new file mode 100644 index 0000000..e7583fc Binary files /dev/null and b/sources/fonts/inter-cyrillic-regular.woff2 differ diff --git a/sources/index.html b/sources/index.html index 41e6a00..ba2efc4 100644 --- a/sources/index.html +++ b/sources/index.html @@ -161,6 +161,10 @@ +
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: