Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>.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.
34 changes: 34 additions & 0 deletions sources/css/critical.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
12 changes: 12 additions & 0 deletions sources/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Binary file added sources/fonts/inter-cyrillic-500.woff2
Binary file not shown.
Binary file added sources/fonts/inter-cyrillic-600.woff2
Binary file not shown.
Binary file added sources/fonts/inter-cyrillic-700.woff2
Binary file not shown.
Binary file added sources/fonts/inter-cyrillic-regular.woff2
Binary file not shown.
4 changes: 4 additions & 0 deletions sources/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ <h2 class="menu-title">Options</h2>
<input id="hide-completed" type="checkbox">
<label for="hide-completed">Hide completed tasks</label>
</div>
<div class="menu-item language-option">
<label for="language-select">Language</label>
<select id="language-select"></select>
</div>
<hr>
<button id="reset-daily-button" class="menu-btn">Reset Daily Checks</button>
<button id="reset-weekly-button" class="menu-btn">Reset Weekly Checks</button>
Expand Down
165 changes: 94 additions & 71 deletions sources/js/app.js

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion sources/js/constants.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// --- 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;
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 = '<span title="Orbiter, Drifter\'s Camp, or Backroom">Base of Operations</span>';
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 = `<span title="${t("Orbiter, Drifter's Camp, or Backroom")}">${BASE_OF_OPERATIONS_NAME}</span>`;

export const TASKLIST_STAT_NAMES = {
"completed": "Completed",
Expand Down
19 changes: 14 additions & 5 deletions sources/js/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)}`;
}
Expand Down
146 changes: 146 additions & 0 deletions sources/js/i18n.js
Original file line number Diff line number Diff line change
@@ -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/<code>.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)));
});
}
}
Loading
Loading